diff --git a/changelog.md b/changelog.md index 8e03cbb..ac22caf 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,22 @@ # Changelog +## 2025-07-25 - 4.0.0 - BREAKING CHANGE(core) +Complete stateless architecture - consumers now have full control over session persistence + +- **BREAKING**: Removed all file-based persistence - no more automatic saving to .nogit/ directory +- **BREAKING**: `init()` now returns `ISessionData` that must be persisted by the consumer +- **BREAKING**: API methods like `getAccounts()` now return `{ data, sessionData? }` objects +- Added `ISessionData` interface exposing complete session state including sessionId +- Added `initWithSession(sessionData)` to initialize with previously saved sessions +- Added `exportSession()` and `getSessionData()` methods for session access +- Added `isSessionValid()` to check session validity +- Fixed session destruction to use actual session ID instead of hardcoded '0' +- Added `initOAuthWithExistingInstallation()` for explicit OAuth session handling +- Session refresh now returns updated session data for consumer persistence +- Added `example.stateless.ts` showing session management patterns + +This change gives consumers full control over session persistence strategy (database, Redis, files, etc.) and makes the library suitable for serverless/microservices architectures. + ## 2025-07-22 - 3.1.2 - fix(oauth) Remove OAuth session caching to prevent authentication issues diff --git a/example.stateless.ts b/example.stateless.ts new file mode 100644 index 0000000..9eb7a31 --- /dev/null +++ b/example.stateless.ts @@ -0,0 +1,172 @@ +import * as bunq from './ts/index.js'; + +// Example of stateless usage of the bunq library + +// 1. Initial session creation +async function createNewSession() { + const bunqAccount = new bunq.BunqAccount({ + apiKey: 'your-api-key', + deviceName: 'my-app', + environment: 'PRODUCTION', + }); + + // Initialize and get session data + const sessionData = await bunqAccount.init(); + + // Save session data to your preferred storage (database, file, etc.) + await saveSessionToDatabase(sessionData); + + // Use the account + const { accounts } = await bunqAccount.getAccounts(); + console.log('Found accounts:', accounts.length); + + return sessionData; +} + +// 2. Reusing an existing session +async function reuseExistingSession() { + // Load session data from your storage + const sessionData = await loadSessionFromDatabase(); + + const bunqAccount = new bunq.BunqAccount({ + apiKey: 'your-api-key', + deviceName: 'my-app', + environment: 'PRODUCTION', + }); + + // Initialize with existing session + await bunqAccount.initWithSession(sessionData); + + // Use the account - session refresh happens automatically + const { accounts, sessionData: updatedSession } = await bunqAccount.getAccounts(); + + // If session was refreshed, save the updated session data + if (updatedSession) { + await saveSessionToDatabase(updatedSession); + } + + return accounts; +} + +// 3. OAuth token with existing installation +async function oauthWithExistingInstallation() { + const bunqAccount = new bunq.BunqAccount({ + apiKey: 'oauth-access-token', + deviceName: 'my-oauth-app', + environment: 'PRODUCTION', + isOAuthToken: true, + }); + + try { + // Try normal initialization + const sessionData = await bunqAccount.init(); + await saveSessionToDatabase(sessionData); + } catch (error) { + // If OAuth token already has installation, use existing + const existingInstallation = await loadInstallationFromDatabase(); + const sessionData = await bunqAccount.initOAuthWithExistingInstallation(existingInstallation); + await saveSessionToDatabase(sessionData); + } +} + +// 4. Session validation +async function validateAndRefreshSession() { + const sessionData = await loadSessionFromDatabase(); + + const bunqAccount = new bunq.BunqAccount({ + apiKey: 'your-api-key', + deviceName: 'my-app', + environment: 'PRODUCTION', + }); + + try { + await bunqAccount.initWithSession(sessionData); + + if (!bunqAccount.isSessionValid()) { + // Session expired, create new one + const newSessionData = await bunqAccount.init(); + await saveSessionToDatabase(newSessionData); + } + } catch (error) { + // Session invalid, create new one + const newSessionData = await bunqAccount.init(); + await saveSessionToDatabase(newSessionData); + } +} + +// 5. Complete example with error handling +async function completeExample() { + let bunqAccount: bunq.BunqAccount; + let sessionData: bunq.ISessionData; + + try { + // Try to load existing session + const existingSession = await loadSessionFromDatabase(); + + bunqAccount = new bunq.BunqAccount({ + apiKey: process.env.BUNQ_API_KEY!, + deviceName: 'my-production-app', + environment: 'PRODUCTION', + }); + + if (existingSession) { + try { + await bunqAccount.initWithSession(existingSession); + console.log('Reused existing session'); + } catch (error) { + // Session invalid, create new one + sessionData = await bunqAccount.init(); + await saveSessionToDatabase(sessionData); + console.log('Created new session'); + } + } else { + // No existing session, create new one + sessionData = await bunqAccount.init(); + await saveSessionToDatabase(sessionData); + console.log('Created new session'); + } + + // Use the API + const { accounts, sessionData: updatedSession } = await bunqAccount.getAccounts(); + + // Save updated session if it was refreshed + if (updatedSession) { + await saveSessionToDatabase(updatedSession); + console.log('Session was refreshed'); + } + + // Make a payment + const account = accounts[0]; + const payment = await bunq.BunqPayment.builder(bunqAccount, account) + .amount('10.00', 'EUR') + .toIban('NL91ABNA0417164300', 'Test Recipient') + .description('Test payment') + .create(); + + console.log('Payment created:', payment.id); + + // Clean up + await bunqAccount.stop(); + + } catch (error) { + console.error('Error:', error); + } +} + +// Mock storage functions (implement these with your actual storage) +async function saveSessionToDatabase(sessionData: bunq.ISessionData): Promise { + // Implement your storage logic here + // Example: await db.sessions.save(sessionData); +} + +async function loadSessionFromDatabase(): Promise { + // Implement your storage logic here + // Example: return await db.sessions.findLatest(); + return null; +} + +async function loadInstallationFromDatabase(): Promise | undefined> { + // Load just the installation data needed for OAuth + // Example: return await db.installations.findByApiKey(); + return undefined; +} \ No newline at end of file diff --git a/package.json b/package.json index 304b3a2..e65b5e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apiclient.xyz/bunq", - "version": "3.1.2", + "version": "4.0.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..21577dc 100644 --- a/readme.md +++ b/readme.md @@ -27,6 +27,25 @@ A powerful, type-safe TypeScript/JavaScript client for the bunq API with full fe - 🛡️ **Type Safety** - Compile-time type checking for all operations - 📚 **Comprehensive Documentation** - Detailed examples for every feature +## Stateless Architecture (v4.0.0+) + +Starting from version 4.0.0, this library is completely stateless. Session management is now entirely controlled by the consumer: + +### Key Changes +- **No File Persistence** - The library no longer saves any state to disk +- **Session Data Export** - Full session data is returned for you to persist +- **Session Data Import** - Initialize with previously saved session data +- **Explicit Session Management** - You control when and how sessions are stored + +### Benefits +- **Full Control** - Store sessions in your preferred storage (database, Redis, etc.) +- **Better for Microservices** - No shared state between instances +- **Improved Testing** - Predictable behavior with no hidden state +- **Enhanced Security** - You control where sensitive data is stored + +### Migration from v3.x +If you're upgrading from v3.x, you'll need to handle session persistence yourself. See the [Stateless Session Management](#stateless-session-management) section for examples. + ## Installation ```bash @@ -53,13 +72,21 @@ const bunq = new BunqAccount({ environment: 'PRODUCTION' // or 'SANDBOX' for testing }); -// Initialize connection -await bunq.init(); +// Initialize connection and get session data +const sessionData = await bunq.init(); + +// IMPORTANT: Save the session data for reuse +await saveSessionToDatabase(sessionData); // Get your accounts -const accounts = await bunq.getAccounts(); +const { accounts, sessionData: updatedSession } = await bunq.getAccounts(); console.log(`Found ${accounts.length} accounts`); +// If session was refreshed, save the updated data +if (updatedSession) { + await saveSessionToDatabase(updatedSession); +} + // Get recent transactions const transactions = await accounts[0].getTransactions(); transactions.forEach(tx => { @@ -380,13 +407,73 @@ await new ExportBuilder(bunq, account) .downloadTo('/path/to/statement-with-attachments.pdf'); ``` -### User & Session Management +### Stateless Session Management + +```typescript +// Initial session creation +const bunq = new BunqAccount({ + apiKey: 'your-api-key', + deviceName: 'My App', + environment: 'PRODUCTION' +}); + +// Initialize and receive session data +const sessionData = await bunq.init(); + +// Save to your preferred storage +await saveToDatabase({ + userId: 'user123', + sessionData: sessionData, + createdAt: new Date() +}); + +// Reusing existing session +const savedData = await loadFromDatabase('user123'); +const bunq2 = new BunqAccount({ + apiKey: 'your-api-key', + deviceName: 'My App', + environment: 'PRODUCTION' +}); + +// Initialize with saved session +await bunq2.initWithSession(savedData.sessionData); + +// Check if session is valid +if (!bunq2.isSessionValid()) { + // Session expired, create new one + const newSession = await bunq2.init(); + await saveToDatabase({ userId: 'user123', sessionData: newSession }); +} + +// Making API calls with automatic refresh +const { accounts, sessionData: refreshedSession } = await bunq2.getAccounts(); + +// Always save refreshed session data +if (refreshedSession) { + await saveToDatabase({ + userId: 'user123', + sessionData: refreshedSession, + updatedAt: new Date() + }); +} + +// Get current session data at any time +const currentSession = bunq2.getSessionData(); +``` + +### User Management ```typescript // Get user information const user = await bunq.getUser(); -console.log(`Logged in as: ${user.displayName}`); -console.log(`User type: ${user.type}`); // UserPerson, UserCompany, etc. +const userInfo = await user.getInfo(); + +// Determine user type +if (userInfo.UserPerson) { + console.log(`Personal account: ${userInfo.UserPerson.display_name}`); +} else if (userInfo.UserCompany) { + console.log(`Business account: ${userInfo.UserCompany.name}`); +} // Update user settings await user.update({ @@ -395,21 +482,6 @@ await user.update({ { category: 'PAYMENT', notificationDeliveryMethod: 'PUSH' } ] }); - -// Session management -const session = bunq.apiContext.getSession(); -console.log(`Session expires: ${session.expiryTime}`); - -// Manual session refresh -await bunq.apiContext.refreshSession(); - -// Save session for later use -const sessionData = bunq.apiContext.exportSession(); -await fs.writeFile('bunq-session.json', JSON.stringify(sessionData)); - -// Restore session -const savedSession = JSON.parse(await fs.readFile('bunq-session.json')); -bunq.apiContext.importSession(savedSession); ``` ## Advanced Usage @@ -436,19 +508,34 @@ const bunq = new BunqAccount({ apiKey: 'your-oauth-access-token', // OAuth token from bunq OAuth flow deviceName: 'OAuth App', environment: 'PRODUCTION', - isOAuthToken: true // Optional: Set for OAuth-specific handling + isOAuthToken: true // Important for OAuth-specific handling }); -await bunq.init(); +try { + // Try normal initialization + const sessionData = await bunq.init(); + await saveOAuthSession(sessionData); +} catch (error) { + // OAuth token may already have installation/device + if (error.message.includes('already has a user session')) { + // Load existing installation data if available + const existingInstallation = await loadOAuthInstallation(); + + // Initialize with existing installation + const sessionData = await bunq.initOAuthWithExistingInstallation(existingInstallation); + await saveOAuthSession(sessionData); + } else { + throw error; + } +} -// OAuth tokens work just like regular API keys: +// Use the OAuth-initialized account normally +const { accounts, sessionData } = await bunq.getAccounts(); + +// OAuth tokens work like regular API keys: // 1. They go through installation → device → session creation // 2. The OAuth token is used as the 'secret' during authentication // 3. A session token is created and used for all API calls -const accounts = await bunq.getAccounts(); - -// According to bunq documentation: -// "Just use the OAuth Token (access_token) as a normal bunq API key" ``` ### Error Handling @@ -590,6 +677,67 @@ await bunqJSClient.registerSession(); await bunq.init(); ``` +## Migration Guide from v3.x to v4.0.0 + +Version 4.0.0 introduces a breaking change: the library is now completely stateless. Here's how to migrate: + +### Before (v3.x) +```typescript +// Session was automatically saved to .nogit/bunqproduction.json +const bunq = new BunqAccount({ apiKey, deviceName, environment }); +await bunq.init(); // Session saved to disk automatically +const accounts = await bunq.getAccounts(); // Returns accounts directly +``` + +### After (v4.0.0) +```typescript +// You must handle session persistence yourself +const bunq = new BunqAccount({ apiKey, deviceName, environment }); +const sessionData = await bunq.init(); // Returns session data +await myDatabase.save('session', sessionData); // You save it + +// API calls now return both data and potentially refreshed session +const { accounts, sessionData: newSession } = await bunq.getAccounts(); +if (newSession) { + await myDatabase.save('session', newSession); // Save refreshed session +} +``` + +### Key Changes +1. **No automatic file persistence** - Remove any dependency on `.nogit/` files +2. **`init()` returns session data** - You must save this data yourself +3. **API methods return objects** - Methods like `getAccounts()` now return `{ accounts, sessionData? }` +4. **Session reuse requires explicit loading** - Use `initWithSession(savedData)` +5. **OAuth handling is explicit** - Use `initOAuthWithExistingInstallation()` for OAuth tokens with existing installations + +### Session Storage Example +```typescript +// Simple file-based storage (similar to v3.x behavior) +import { promises as fs } from 'fs'; + +async function saveSession(data: ISessionData) { + await fs.writeFile('./my-session.json', JSON.stringify(data)); +} + +async function loadSession(): Promise { + try { + const data = await fs.readFile('./my-session.json', 'utf-8'); + return JSON.parse(data); + } catch { + return null; + } +} + +// Database storage example +async function saveSessionToDB(userId: string, data: ISessionData) { + await db.collection('bunq_sessions').updateOne( + { userId }, + { $set: { sessionData: data, updatedAt: new Date() } }, + { upsert: true } + ); +} +``` + ## Testing The library includes comprehensive test coverage: diff --git a/ts/bunq.classes.account.ts b/ts/bunq.classes.account.ts index e7c4413..c545ffb 100644 --- a/ts/bunq.classes.account.ts +++ b/ts/bunq.classes.account.ts @@ -3,7 +3,7 @@ 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'; +import type { IBunqSessionServerResponse, ISessionData } from './bunq.interfaces.js'; export interface IBunqConstructorOptions { deviceName: string; @@ -30,8 +30,9 @@ export class BunqAccount { /** * Initialize the bunq account + * @returns The session data that can be persisted by the consumer */ - public async init() { + public async init(): Promise { // Create API context for both OAuth tokens and regular API keys this.apiContext = new BunqApiContext({ apiKey: this.options.apiKey, @@ -41,8 +42,10 @@ export class BunqAccount { isOAuthToken: this.options.isOAuthToken }); + let sessionData: ISessionData; + try { - await this.apiContext.init(); + sessionData = await this.apiContext.init(); } catch (error) { // Handle "Superfluous authentication" or "Authentication token already has a user session" errors if (error instanceof BunqApiError && this.options.isOAuthToken) { @@ -51,7 +54,7 @@ export class BunqAccount { errorMessages.includes('Authentication token already has a user session')) { console.log('OAuth token already has installation/device, attempting to create new session...'); // Try to create a new session with existing installation/device - await this.apiContext.initWithExistingInstallation(); + sessionData = await this.apiContext.initWithExistingInstallation(); } else { throw error; } @@ -65,6 +68,27 @@ export class BunqAccount { // Get user info await this.getUserInfo(); + + return sessionData; + } + + /** + * Initialize the bunq account with existing session data + * @param sessionData The session data to restore + */ + public async initWithSession(sessionData: ISessionData): Promise { + // Create API context with existing session + this.apiContext = await BunqApiContext.createWithSession( + sessionData, + this.options.apiKey, + this.options.deviceName + ); + + // Create user instance + this.bunqUser = new BunqUser(this.apiContext); + + // Get user info + await this.getUserInfo(); } /** @@ -89,9 +113,10 @@ export class BunqAccount { /** * Get all monetary accounts + * @returns An array of monetary accounts and updated session data if session was refreshed */ - public async getAccounts(): Promise { - await this.apiContext.ensureValidSession(); + public async getAccounts(): Promise<{ accounts: BunqMonetaryAccount[], sessionData?: ISessionData }> { + const sessionData = await this.apiContext.ensureValidSession(); const response = await this.apiContext.getHttpClient().list( `/v1/user/${this.userId}/monetary-account` @@ -105,21 +130,23 @@ export class BunqAccount { } } - return accountsArray; + return { accounts: accountsArray, sessionData: sessionData || undefined }; } /** * Get a specific monetary account + * @returns The monetary account and updated session data if session was refreshed */ - public async getAccount(accountId: number): Promise { - await this.apiContext.ensureValidSession(); + public async getAccount(accountId: number): Promise<{ account: BunqMonetaryAccount, sessionData?: ISessionData }> { + const sessionData = await this.apiContext.ensureValidSession(); const response = await this.apiContext.getHttpClient().get( `/v1/user/${this.userId}/monetary-account/${accountId}` ); if (response.Response && response.Response[0]) { - return BunqMonetaryAccount.fromAPIObject(this, response.Response[0]); + const account = BunqMonetaryAccount.fromAPIObject(this, response.Response[0]); + return { account, sessionData: sessionData || undefined }; } throw new Error('Account not found'); @@ -168,6 +195,54 @@ export class BunqAccount { return this.apiContext.getHttpClient(); } + /** + * Get the current session data for persistence + * @returns The current session data + */ + public getSessionData(): ISessionData { + return this.apiContext.exportSession(); + } + + /** + * Try to initialize with OAuth token using existing installation + * This is useful when you know the OAuth token already has installation/device + * @param existingInstallation Optional partial session data with installation info + * @returns The session data + */ + public async initOAuthWithExistingInstallation(existingInstallation?: Partial): Promise { + if (!this.options.isOAuthToken) { + throw new Error('This method is only for OAuth tokens'); + } + + // Create API context + this.apiContext = new BunqApiContext({ + apiKey: this.options.apiKey, + environment: this.options.environment, + deviceDescription: this.options.deviceName, + permittedIps: this.options.permittedIps, + isOAuthToken: true + }); + + // Initialize with existing installation + const sessionData = await this.apiContext.initWithExistingInstallation(existingInstallation); + + // Create user instance + this.bunqUser = new BunqUser(this.apiContext); + + // Get user info + await this.getUserInfo(); + + return sessionData; + } + + /** + * Check if the current session is valid + * @returns True if session is valid + */ + public isSessionValid(): boolean { + return this.apiContext && this.apiContext.hasValidSession(); + } + /** * Stop the bunq account and clean up */ diff --git a/ts/bunq.classes.apicontext.ts b/ts/bunq.classes.apicontext.ts index 66eb78d..0e44b79 100644 --- a/ts/bunq.classes.apicontext.ts +++ b/ts/bunq.classes.apicontext.ts @@ -1,8 +1,7 @@ import * as plugins from './bunq.plugins.js'; -import * as paths from './bunq.paths.js'; import { BunqCrypto } from './bunq.classes.crypto.js'; import { BunqSession } from './bunq.classes.session.js'; -import type { IBunqApiContext } from './bunq.interfaces.js'; +import type { IBunqApiContext, ISessionData } from './bunq.interfaces.js'; export interface IBunqApiContextOptions { apiKey: string; @@ -17,7 +16,6 @@ export class BunqApiContext { private crypto: BunqCrypto; private session: BunqSession; private context: IBunqApiContext; - private contextFilePath: string; constructor(options: IBunqApiContextOptions) { this.options = options; @@ -32,38 +30,14 @@ export class BunqApiContext { : 'https://public-api.sandbox.bunq.com' }; - // Set context file path based on environment - this.contextFilePath = options.environment === 'PRODUCTION' - ? paths.bunqJsonProductionFile - : paths.bunqJsonSandboxFile; - this.session = new BunqSession(this.crypto, this.context); } /** * Initialize the API context (installation, device, session) + * @returns The session data that can be persisted by the consumer */ - public async init(): Promise { - // Try to load existing context - const existingContext = await this.loadContext(); - - if (existingContext && existingContext.sessionToken) { - // Restore crypto keys - this.crypto.setKeys( - existingContext.clientPrivateKey, - existingContext.clientPublicKey - ); - - // Update context - this.context = { ...this.context, ...existingContext }; - this.session = new BunqSession(this.crypto, this.context); - - // Check if session is still valid - if (this.session.isSessionValid()) { - return; - } - } - + public async init(): Promise { // Create new session await this.session.init( this.options.deviceDescription, @@ -75,42 +49,96 @@ export class BunqApiContext { this.session.setOAuthMode(true); } - // Save context - await this.saveContext(); + return this.exportSession(); } /** - * Save the current context to file + * Initialize the API context with existing session data + * @param sessionData The session data to restore */ - private async saveContext(): Promise { - await plugins.smartfile.fs.ensureDir(paths.nogitDir); - - const contextToSave = { - ...this.session.getContext(), - savedAt: new Date().toISOString() - }; - - await plugins.smartfile.memory.toFs( - JSON.stringify(contextToSave, null, 2), - this.contextFilePath - ); - } - - /** - * Load context from file - */ - private async loadContext(): Promise { - try { - const exists = await plugins.smartfile.fs.fileExists(this.contextFilePath); - if (!exists) { - return null; - } - - const contextData = await plugins.smartfile.fs.toStringSync(this.contextFilePath); - return JSON.parse(contextData); - } catch (error) { - return null; + public async initWithSession(sessionData: ISessionData): Promise { + // Validate session data + if (!sessionData.sessionToken || !sessionData.sessionId) { + throw new Error('Invalid session data: missing session token or ID'); } + + // Restore crypto keys + this.crypto.setKeys( + sessionData.clientPrivateKey, + sessionData.clientPublicKey + ); + + // Update context with session data + this.context = { + ...this.context, + sessionToken: sessionData.sessionToken, + sessionId: sessionData.sessionId, + installationToken: sessionData.installationToken, + serverPublicKey: sessionData.serverPublicKey, + clientPrivateKey: sessionData.clientPrivateKey, + clientPublicKey: sessionData.clientPublicKey, + expiresAt: new Date(sessionData.expiresAt) + }; + + // Create new session instance with restored context + this.session = new BunqSession(this.crypto, this.context); + + // Set OAuth mode if applicable + if (this.options.isOAuthToken) { + this.session.setOAuthMode(true); + } + + // Check if session is still valid + if (!this.session.isSessionValid()) { + throw new Error('Session has expired'); + } + } + + /** + * Export the current session data for persistence + * @returns The session data that can be saved by the consumer + */ + public exportSession(): ISessionData { + const context = this.session.getContext(); + + if (!context.sessionToken || !context.sessionId) { + throw new Error('No active session to export'); + } + + return { + sessionId: context.sessionId, + sessionToken: context.sessionToken, + installationToken: context.installationToken!, + serverPublicKey: context.serverPublicKey!, + clientPrivateKey: context.clientPrivateKey!, + clientPublicKey: context.clientPublicKey!, + expiresAt: context.expiresAt!, + environment: context.environment, + baseUrl: context.baseUrl + }; + } + + /** + * Create a new BunqApiContext with existing session data + * @param sessionData The session data to use + * @param apiKey The API key (still needed for refresh) + * @param deviceDescription Device description + * @returns A new BunqApiContext instance + */ + public static async createWithSession( + sessionData: ISessionData, + apiKey: string, + deviceDescription: string + ): Promise { + const context = new BunqApiContext({ + apiKey, + environment: sessionData.environment, + deviceDescription, + isOAuthToken: false // Set appropriately based on your needs + }); + + await context.initWithSession(sessionData); + return context; } /** @@ -129,24 +157,24 @@ export class BunqApiContext { /** * Refresh session if needed + * @returns Updated session data if session was refreshed, null otherwise */ - public async ensureValidSession(): Promise { + public async ensureValidSession(): Promise { + const wasValid = this.session.isSessionValid(); await this.session.refreshSession(); - await this.saveContext(); + + // Return updated session data only if session was actually refreshed + if (!wasValid) { + return this.exportSession(); + } + return null; } /** - * Destroy the current session and clean up + * Destroy the current session */ public async destroy(): Promise { await this.session.destroySession(); - - // Remove saved context - try { - await plugins.smartfile.fs.remove(this.contextFilePath); - } catch (error) { - // Ignore errors when removing file - } } /** @@ -172,24 +200,27 @@ export class BunqApiContext { /** * Initialize with existing installation and device (for OAuth tokens that already completed these steps) + * @param existingInstallation Optional partial session data with just installation/device info + * @returns The new session data */ - public async initWithExistingInstallation(): Promise { + public async initWithExistingInstallation(existingInstallation?: Partial): Promise { // For OAuth tokens that already have installation/device but need a new session - // We need to: - // 1. Try to load existing installation/device info - // 2. Create a new session using the OAuth token as the secret - const existingContext = await this.loadContext(); - - if (existingContext && existingContext.clientPrivateKey && existingContext.clientPublicKey) { + if (existingInstallation && existingInstallation.clientPrivateKey && existingInstallation.clientPublicKey) { // Restore crypto keys from previous installation this.crypto.setKeys( - existingContext.clientPrivateKey, - existingContext.clientPublicKey + existingInstallation.clientPrivateKey, + existingInstallation.clientPublicKey ); // Update context with existing installation data - this.context = { ...this.context, ...existingContext }; + this.context = { + ...this.context, + installationToken: existingInstallation.installationToken, + serverPublicKey: existingInstallation.serverPublicKey, + clientPrivateKey: existingInstallation.clientPrivateKey, + clientPublicKey: existingInstallation.clientPublicKey + }; // Create new session instance this.session = new BunqSession(this.crypto, this.context); @@ -206,14 +237,14 @@ export class BunqApiContext { this.session.setOAuthMode(true); } - await this.saveContext(); console.log('Successfully created new session with existing installation'); + return this.exportSession(); } catch (error) { throw new Error(`Failed to create session with OAuth token: ${error.message}`); } } else { // No existing installation, fall back to full init - throw new Error('No existing installation found, full initialization required'); + throw new Error('No existing installation provided, full initialization required'); } } } \ No newline at end of file diff --git a/ts/bunq.classes.session.ts b/ts/bunq.classes.session.ts index e35cc95..dadbaef 100644 --- a/ts/bunq.classes.session.ts +++ b/ts/bunq.classes.session.ts @@ -109,11 +109,15 @@ export class BunqSession { secret: this.context.apiKey }); - // Extract session token and user info + // Extract session token, session ID, and user info let sessionToken: string; + let sessionId: number; let userId: number; for (const item of response.Response) { + if (item.Id) { + sessionId = item.Id.id; + } if (item.Token) { sessionToken = item.Token.token; } @@ -126,12 +130,13 @@ export class BunqSession { } } - if (!sessionToken || !userId) { + if (!sessionToken || !userId || !sessionId) { throw new Error('Failed to create session'); } // Update context this.context.sessionToken = sessionToken; + this.context.sessionId = sessionId; // Update HTTP client context this.httpClient.updateContext({ @@ -140,6 +145,7 @@ export class BunqSession { // Set session expiry (bunq sessions expire after 10 minutes of inactivity) this.sessionExpiryTime = plugins.smarttime.TimeStamp.fromMilliSeconds(Date.now() + 600000); + this.context.expiresAt = new Date(Date.now() + 600000); } /** @@ -150,7 +156,9 @@ export class BunqSession { if (isOAuth) { // OAuth tokens don't expire in the same way as regular sessions // Set a far future expiry time - this.sessionExpiryTime = plugins.smarttime.TimeStamp.fromMilliSeconds(Date.now() + 365 * 24 * 60 * 60 * 1000); + const farFutureTime = Date.now() + 365 * 24 * 60 * 60 * 1000; + this.sessionExpiryTime = plugins.smarttime.TimeStamp.fromMilliSeconds(farFutureTime); + this.context.expiresAt = new Date(farFutureTime); } } @@ -192,12 +200,13 @@ export class BunqSession { } /** - * Get the current session ID from the token + * Get the current session ID */ private getSessionId(): string { - // In a real implementation, we would need to store the session ID - // For now, return a placeholder - return '0'; + if (!this.context.sessionId) { + throw new Error('Session ID not available'); + } + return this.context.sessionId.toString(); } /** diff --git a/ts/bunq.interfaces.ts b/ts/bunq.interfaces.ts index 88cca97..7b516b1 100644 --- a/ts/bunq.interfaces.ts +++ b/ts/bunq.interfaces.ts @@ -4,9 +4,23 @@ export interface IBunqApiContext { baseUrl: string; installationToken?: string; sessionToken?: string; + sessionId?: number; serverPublicKey?: string; clientPrivateKey?: string; clientPublicKey?: string; + expiresAt?: Date; +} + +export interface ISessionData { + sessionId: number; + sessionToken: string; + installationToken: string; + serverPublicKey: string; + clientPrivateKey: string; + clientPublicKey: string; + expiresAt: Date; + environment: 'SANDBOX' | 'PRODUCTION'; + baseUrl: string; } export interface IBunqError { diff --git a/ts/bunq.paths.ts b/ts/bunq.paths.ts deleted file mode 100644 index 1914029..0000000 --- a/ts/bunq.paths.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as plugins from './bunq.plugins.js'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -export const packageDir = plugins.path.join(__dirname, '../'); -export const nogitDir = plugins.path.join(packageDir, './.nogit/'); - -export const bunqJsonProductionFile = plugins.path.join(nogitDir, 'bunqproduction.json'); -export const bunqJsonSandboxFile = plugins.path.join(nogitDir, 'bunqsandbox.json');