-
Notifications
You must be signed in to change notification settings - Fork 1
feat(session): add configurable session encoding with migration support #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,16 @@ import type { | |
| SessionEncryption, | ||
| } from './session/types.js'; | ||
|
|
||
| function encodeUnsealed(session: Session): string { | ||
| return Buffer.from(JSON.stringify(session)).toString('base64url'); | ||
| } | ||
|
|
||
| function decodeUnsealed(encoded: string): Session { | ||
| return JSON.parse( | ||
| Buffer.from(encoded, 'base64url').toString('utf-8'), | ||
| ) as Session; | ||
| } | ||
|
Comment on lines
+17
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Since errors from function decodeUnsealed(encoded: string): Session {
const parsed = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf-8'));
if (!parsed || typeof parsed !== 'object' || !parsed.accessToken || !parsed.refreshToken) {
throw new Error('Decoded value is not a valid Session');
}
return parsed as Session;
} |
||
|
|
||
| /** | ||
| * AuthKitCore provides pure business logic for authentication operations. | ||
| * | ||
|
|
@@ -111,37 +121,89 @@ export class AuthKitCore { | |
|
|
||
| /** | ||
| * Encrypt a session object into a string suitable for cookie storage. | ||
| * Respects sessionEncoding.write config: 'sealed' (default) or 'unsealed'. | ||
| * | ||
| * @param session - The session to encrypt | ||
| * @returns Encrypted session string | ||
| * @returns Sealed or base64url-encoded session string | ||
| * @throws SessionEncryptionError if encryption fails | ||
| */ | ||
| async encryptSession(session: Session): Promise<string> { | ||
| const { write } = this.config.sessionEncoding ?? { write: 'sealed' }; | ||
|
|
||
| if (write === 'unsealed') { | ||
| return encodeUnsealed(session); | ||
| } | ||
|
|
||
| // write === 'sealed' | ||
| if (!this.config.cookiePassword) { | ||
| throw new SessionEncryptionError( | ||
| 'cookiePassword is required for sealed sessions', | ||
| ); | ||
| } | ||
| try { | ||
| const encryptedSession = await this.encryption.sealData(session, { | ||
| return await this.encryption.sealData(session, { | ||
| password: this.config.cookiePassword, | ||
| ttl: 0, | ||
| }); | ||
| return encryptedSession; | ||
| } catch (error) { | ||
| throw new SessionEncryptionError('Failed to encrypt session', error); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Decrypt an encrypted session string back into a session object. | ||
| * Decrypt a session string back into a session object. | ||
| * Respects sessionEncoding.read config: | ||
| * - 'sealed' (default): iron-unseal only | ||
| * - 'unsealed': base64url decode only | ||
| * - 'both': try sealed first, fall back to unsealed | ||
| * | ||
| * @param encryptedSession - The encrypted session string | ||
| * @param encryptedSession - The sealed or base64url-encoded session string | ||
| * @returns Decrypted session object | ||
| * @throws SessionEncryptionError if decryption fails | ||
| */ | ||
| async decryptSession(encryptedSession: string): Promise<Session> { | ||
| try { | ||
| const session = await this.encryption.unsealData<Session>( | ||
| encryptedSession, | ||
| { password: this.config.cookiePassword }, | ||
| const { read } = this.config.sessionEncoding ?? { read: 'sealed' }; | ||
|
|
||
| if (read === 'unsealed') { | ||
| try { | ||
| return decodeUnsealed(encryptedSession); | ||
| } catch (error) { | ||
| throw new SessionEncryptionError( | ||
| 'Failed to decode unsealed session', | ||
| error, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| if (read === 'both') { | ||
| try { | ||
| return await this.unsealSession(encryptedSession); | ||
| } catch { | ||
| try { | ||
| return decodeUnsealed(encryptedSession); | ||
| } catch (error) { | ||
| throw new SessionEncryptionError( | ||
| 'Failed to decode session in both sealed and unsealed modes', | ||
| error, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // read === 'sealed' (default) | ||
| return this.unsealSession(encryptedSession); | ||
| } | ||
|
|
||
| private async unsealSession(encryptedSession: string): Promise<Session> { | ||
| if (!this.config.cookiePassword) { | ||
| throw new SessionEncryptionError( | ||
| 'cookiePassword is required for sealed sessions', | ||
| ); | ||
| return session; | ||
| } | ||
| try { | ||
| return await this.encryption.unsealData<Session>(encryptedSession, { | ||
| password: this.config.cookiePassword, | ||
| }); | ||
| } catch (error) { | ||
| throw new SessionEncryptionError('Failed to decrypt session', error); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
decodeUnsealedperforms a bareJSON.parsewith a type cast but no integrity verification. In contrast, sealed mode uses iron-webcrypto which provides both encryption and HMAC authentication — meaning any cookie modification is detected and rejected.In unsealed mode, a user who can access their browser's cookie storage can:
userobject (email, name, metadata) or inject animpersonatorfieldThis is especially problematic because
validateAndRefreshreturnssession(the cookie-derived object) directly when the JWT is still valid — and there is no cross-validation between the verifiedclaims.suband the unverifiedsession.user.id. The caller therefore receives a fully trustedauth.userandauth.impersonatorthat are entirely unauthenticated in unsealed mode.The issue also affects
read: 'both'mode. When migrating from unsealed → sealed ({ read: 'both', write: 'sealed' }), an attacker can deliberately present a hand-crafted unsealed JSON blob:unsealSessionwill fail on it (not a valid iron token), and the fallback path indecryptSessionwill accept it without any password or integrity check.If a pure-encoding format (no crypto) is the intended long-term design, consider at minimum adding an HMAC signature over the JSON payload with the
cookiePasswordso tampered cookies are rejected — or document clearly that applications must not rely on session-cookie user data for any authoritative identity or access-control decision and instead useclaims(the verified JWT payload) exclusively.