BREAKING CHANGE(core): implement complete stateless architecture with consumer-controlled session persistence
This commit is contained in:
@@ -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<ISessionData> {
|
||||
// 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<void> {
|
||||
// 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<BunqMonetaryAccount[]> {
|
||||
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<BunqMonetaryAccount> {
|
||||
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<ISessionData>): Promise<ISessionData> {
|
||||
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
|
||||
*/
|
||||
|
@@ -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<void> {
|
||||
// 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<ISessionData> {
|
||||
// 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<void> {
|
||||
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<IBunqApiContext | null> {
|
||||
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<void> {
|
||||
// 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<BunqApiContext> {
|
||||
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<void> {
|
||||
public async ensureValidSession(): Promise<ISessionData | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
public async initWithExistingInstallation(existingInstallation?: Partial<ISessionData>): Promise<ISessionData> {
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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 {
|
||||
|
@@ -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');
|
Reference in New Issue
Block a user