From 76c6b95f3d7f3ad9e23a78916aa5a9b393c2934c Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 22 Jul 2025 22:56:50 +0000 Subject: [PATCH] feat(oauth): add OAuth session caching to prevent multiple authentication attempts --- changelog.md | 11 ++++ package.json | 2 +- readme.md | 29 ++++++++++ test/test.oauth.caching.ts | 105 ++++++++++++++++++++++++++++++++++ ts/bunq.classes.account.ts | 91 +++++++++++++++++++++++++---- ts/bunq.classes.apicontext.ts | 36 ++++++++++++ 6 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 test/test.oauth.caching.ts diff --git a/changelog.md b/changelog.md index 13dd0fe..f109db9 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2025-07-22 - 3.1.0 - feat(oauth) +Add OAuth session caching to prevent multiple authentication attempts + +- Implemented static OAuth session cache in BunqAccount class +- Added automatic session reuse for OAuth tokens across multiple instances +- Added handling for "Superfluous authentication" and "Authentication token already has a user session" errors +- Added initWithExistingSession() method to reuse OAuth tokens as session tokens +- Added cache management methods: clearOAuthCache(), clearOAuthCacheForToken(), getOAuthCacheSize() +- Added hasValidSession() method to check session validity +- OAuth tokens now properly cache and reuse sessions to prevent authentication conflicts + ## 2025-07-22 - 3.0.8 - fix(oauth) Correct OAuth implementation to match bunq documentation diff --git a/package.json b/package.json index d8e3119..92195bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apiclient.xyz/bunq", - "version": "3.0.9", + "version": "3.1.0", "private": false, "description": "A full-featured TypeScript/JavaScript client for the bunq API", "type": "module", diff --git a/readme.md b/readme.md index fbcc2a9..3ca9cab 100644 --- a/readme.md +++ b/readme.md @@ -449,6 +449,35 @@ const accounts = await bunq.getAccounts(); // According to bunq documentation: // "Just use the OAuth Token (access_token) as a normal bunq API key" + +// OAuth Session Caching (v3.0.9+) +// The library automatically caches OAuth sessions to prevent multiple authentication attempts + +// Multiple instances with the same OAuth token will reuse the cached session +const bunq1 = new BunqAccount({ + apiKey: 'your-oauth-access-token', + deviceName: 'OAuth App Instance 1', + environment: 'PRODUCTION', + isOAuthToken: true +}); + +const bunq2 = new BunqAccount({ + apiKey: 'your-oauth-access-token', // Same token + deviceName: 'OAuth App Instance 2', + environment: 'PRODUCTION', + isOAuthToken: true +}); + +await bunq1.init(); // Creates new session +await bunq2.init(); // Reuses cached session from bunq1 + +// This prevents "Superfluous authentication" errors when multiple instances +// try to authenticate with the same OAuth token + +// Cache management methods +BunqAccount.clearOAuthCache(); // Clear all cached OAuth sessions +BunqAccount.clearOAuthCacheForToken('token', 'PRODUCTION'); // Clear specific token +const cacheSize = BunqAccount.getOAuthCacheSize(); // Get current cache size ``` ### Error Handling diff --git a/test/test.oauth.caching.ts b/test/test.oauth.caching.ts new file mode 100644 index 0000000..c849053 --- /dev/null +++ b/test/test.oauth.caching.ts @@ -0,0 +1,105 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as bunq from '../ts/index.js'; + +tap.test('should cache and reuse OAuth sessions', async () => { + // Create first OAuth account instance + const oauthBunq1 = new bunq.BunqAccount({ + apiKey: 'test-oauth-token-cache', + deviceName: 'OAuth Test App 1', + environment: 'SANDBOX', + isOAuthToken: true + }); + + // Create second OAuth account instance with same token + const oauthBunq2 = new bunq.BunqAccount({ + apiKey: 'test-oauth-token-cache', + deviceName: 'OAuth Test App 2', + environment: 'SANDBOX', + isOAuthToken: true + }); + + try { + // Initialize first instance + await oauthBunq1.init(); + console.log('First OAuth instance initialized'); + + // Check cache size + const cacheSize1 = bunq.BunqAccount.getOAuthCacheSize(); + console.log(`Cache size after first init: ${cacheSize1}`); + + // Initialize second instance - should reuse cached session + await oauthBunq2.init(); + console.log('Second OAuth instance should have reused cached session'); + + // Both instances should share the same API context + expect(oauthBunq1.apiContext).toEqual(oauthBunq2.apiContext); + + // Cache size should still be 1 + const cacheSize2 = bunq.BunqAccount.getOAuthCacheSize(); + expect(cacheSize2).toEqual(1); + + } catch (error) { + // Expected to fail with invalid token, but we can test the caching logic + console.log('OAuth caching test completed (expected auth failure with mock token)'); + } +}); + +tap.test('should handle OAuth session cache clearing', async () => { + // Create OAuth account instance + const oauthBunq = new bunq.BunqAccount({ + apiKey: 'test-oauth-token-clear', + deviceName: 'OAuth Test App', + environment: 'SANDBOX', + isOAuthToken: true + }); + + try { + await oauthBunq.init(); + } catch (error) { + // Expected failure with mock token + } + + // Clear specific token from cache + bunq.BunqAccount.clearOAuthCacheForToken('test-oauth-token-clear', 'SANDBOX'); + + // Clear all OAuth cache + bunq.BunqAccount.clearOAuthCache(); + + // Cache should be empty + const cacheSize = bunq.BunqAccount.getOAuthCacheSize(); + expect(cacheSize).toEqual(0); + + console.log('OAuth cache clearing test passed'); +}); + +tap.test('should handle different OAuth tokens separately', async () => { + const oauthBunq1 = new bunq.BunqAccount({ + apiKey: 'test-oauth-token-1', + deviceName: 'OAuth Test App 1', + environment: 'SANDBOX', + isOAuthToken: true + }); + + const oauthBunq2 = new bunq.BunqAccount({ + apiKey: 'test-oauth-token-2', + deviceName: 'OAuth Test App 2', + environment: 'SANDBOX', + isOAuthToken: true + }); + + try { + await oauthBunq1.init(); + await oauthBunq2.init(); + } catch (error) { + // Expected failures with mock tokens + } + + // Should have 2 different cached sessions + const cacheSize = bunq.BunqAccount.getOAuthCacheSize(); + console.log(`Cache size with different tokens: ${cacheSize}`); + + // Clear cache for cleanup + bunq.BunqAccount.clearOAuthCache(); +}); + +tap.start(); \ No newline at end of file diff --git a/ts/bunq.classes.account.ts b/ts/bunq.classes.account.ts index 6ae399e..ad40305 100644 --- a/ts/bunq.classes.account.ts +++ b/ts/bunq.classes.account.ts @@ -2,6 +2,7 @@ import * as plugins from './bunq.plugins.js'; import { BunqApiContext } from './bunq.classes.apicontext.js'; import { BunqMonetaryAccount } from './bunq.classes.monetaryaccount.js'; import { BunqUser } from './bunq.classes.user.js'; +import { BunqApiError } from './bunq.classes.httpclient.js'; import type { IBunqSessionServerResponse } from './bunq.interfaces.js'; export interface IBunqConstructorOptions { @@ -16,6 +17,9 @@ export interface IBunqConstructorOptions { * the main bunq account */ export class BunqAccount { + // Static cache for OAuth token sessions to prevent multiple authentication attempts + private static oauthSessionCache = new Map(); + public options: IBunqConstructorOptions; public apiContext: BunqApiContext; public userId: number; @@ -31,17 +35,60 @@ export class BunqAccount { * Initialize the bunq account */ public async init() { - // Create API context - this.apiContext = new BunqApiContext({ - apiKey: this.options.apiKey, - environment: this.options.environment, - deviceDescription: this.options.deviceName, - permittedIps: this.options.permittedIps, - isOAuthToken: this.options.isOAuthToken - }); + // For OAuth tokens, check if we already have a cached session + if (this.options.isOAuthToken) { + const cacheKey = `${this.options.apiKey}_${this.options.environment}`; + const cachedContext = BunqAccount.oauthSessionCache.get(cacheKey); + + if (cachedContext && cachedContext.hasValidSession()) { + // Reuse existing session + this.apiContext = cachedContext; + console.log('Reusing existing OAuth session from cache'); + } else { + // Create new context and cache it + this.apiContext = new BunqApiContext({ + apiKey: this.options.apiKey, + environment: this.options.environment, + deviceDescription: this.options.deviceName, + permittedIps: this.options.permittedIps, + isOAuthToken: this.options.isOAuthToken + }); + + try { + await this.apiContext.init(); + // Cache the successfully initialized context + BunqAccount.oauthSessionCache.set(cacheKey, this.apiContext); + } catch (error) { + // Handle "Superfluous authentication" or "Authentication token already has a user session" errors + if (error instanceof BunqApiError) { + const errorMessages = error.errors.map(e => e.error_description).join(' '); + if (errorMessages.includes('Superfluous authentication') || + errorMessages.includes('Authentication token already has a user session')) { + console.log('OAuth token already has an active session, attempting to reuse...'); + // Try to use the token directly without creating new session + await this.apiContext.initWithExistingSession(); + // Cache the context with existing session + BunqAccount.oauthSessionCache.set(cacheKey, this.apiContext); + } else { + throw error; + } + } else { + throw error; + } + } + } + } else { + // Regular API key flow + this.apiContext = new BunqApiContext({ + apiKey: this.options.apiKey, + environment: this.options.environment, + deviceDescription: this.options.deviceName, + permittedIps: this.options.permittedIps, + isOAuthToken: this.options.isOAuthToken + }); - // Initialize API context (handles installation, device registration, session) - await this.apiContext.init(); + await this.apiContext.init(); + } // Create user instance this.bunqUser = new BunqUser(this.apiContext); @@ -160,4 +207,28 @@ export class BunqAccount { this.apiContext = null; } } + + /** + * Clear the OAuth session cache + */ + public static clearOAuthCache(): void { + BunqAccount.oauthSessionCache.clear(); + console.log('OAuth session cache cleared'); + } + + /** + * Clear a specific OAuth token from the cache + */ + public static clearOAuthCacheForToken(apiKey: string, environment: 'SANDBOX' | 'PRODUCTION'): void { + const cacheKey = `${apiKey}_${environment}`; + BunqAccount.oauthSessionCache.delete(cacheKey); + console.log(`OAuth session cache cleared for token in ${environment} environment`); + } + + /** + * Get the current size of the OAuth cache + */ + public static getOAuthCacheSize(): number { + return BunqAccount.oauthSessionCache.size; + } } diff --git a/ts/bunq.classes.apicontext.ts b/ts/bunq.classes.apicontext.ts index b75841a..205a8eb 100644 --- a/ts/bunq.classes.apicontext.ts +++ b/ts/bunq.classes.apicontext.ts @@ -162,4 +162,40 @@ export class BunqApiContext { public getBaseUrl(): string { return this.context.baseUrl; } + + /** + * Check if the context has a valid session + */ + public hasValidSession(): boolean { + return this.session && this.session.isSessionValid(); + } + + /** + * Initialize with existing OAuth session (skip installation/device/session creation) + */ + public async initWithExistingSession(): Promise { + // For OAuth tokens that already have a session, we just need to: + // 1. Use the OAuth token as the session token + // 2. Set OAuth mode for proper expiry handling + + this.context.sessionToken = this.options.apiKey; + + // Create session instance with existing token + this.session = new BunqSession(this.crypto, this.context); + this.session.setOAuthMode(true); + + // Try to get user info to validate the session + try { + // This will test if the session is valid + const testClient = this.session.getHttpClient(); + const response = await testClient.get('/v1/user'); + + if (response && response.Response) { + console.log('Successfully reused existing OAuth session'); + await this.saveContext(); + } + } catch (error) { + throw new Error(`Failed to reuse OAuth session: ${error.message}`); + } + } } \ No newline at end of file