Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 163 additions & 2 deletions src/core/AuthKitCore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ describe('AuthKitCore', () => {
});

describe('encryptSession()', () => {
it('encrypts session data', async () => {
it('encrypts session data (sealed, default)', async () => {
const session = {
accessToken: 'test-token',
refreshToken: 'test-refresh',
Expand Down Expand Up @@ -178,10 +178,53 @@ describe('AuthKitCore', () => {
}),
).rejects.toThrow(SessionEncryptionError);
});

it('write:unsealed produces base64url JSON', async () => {
const unsealed = new AuthKitCore(
{
...mockConfig,
sessionEncoding: { read: 'sealed', write: 'unsealed' },
} as any,
mockClient as any,
mockEncryption as any,
);
const session = {
accessToken: 'tok',
refreshToken: 'ref',
user: mockUser,
impersonator: undefined,
};

const result = await unsealed.encryptSession(session);

// Must be valid base64url (no +, /, =) and decodable as JSON
expect(result).not.toContain('+');
expect(result).not.toContain('/');
expect(result).not.toContain('=');
const decoded = JSON.parse(Buffer.from(result, 'base64url').toString());
expect(decoded.accessToken).toBe('tok');
});

it('write:sealed without cookiePassword throws SessionEncryptionError', async () => {
const noPwCore = new AuthKitCore(
{ ...mockConfig, cookiePassword: undefined } as any,
mockClient as any,
mockEncryption as any,
);

await expect(
noPwCore.encryptSession({
accessToken: 'tok',
refreshToken: 'ref',
user: mockUser,
impersonator: undefined,
}),
).rejects.toThrow(SessionEncryptionError);
});
});

describe('decryptSession()', () => {
it('decrypts session data', async () => {
it('decrypts sealed session data (default)', async () => {
const result = await core.decryptSession('encrypted-data');

expect(result.accessToken).toBe('test-access-token');
Expand All @@ -205,6 +248,124 @@ describe('AuthKitCore', () => {
SessionEncryptionError,
);
});

it('read:unsealed decodes base64url JSON session', async () => {
const session = {
accessToken: 'tok',
refreshToken: 'ref',
user: mockUser,
impersonator: undefined,
};
const encoded = Buffer.from(JSON.stringify(session)).toString(
'base64url',
);

const unsealedCore = new AuthKitCore(
{
...mockConfig,
sessionEncoding: { read: 'unsealed', write: 'unsealed' },
} as any,
mockClient as any,
mockEncryption as any,
);

const result = await unsealedCore.decryptSession(encoded);
expect(result.accessToken).toBe('tok');
expect(result.refreshToken).toBe('ref');
});

it('read:unsealed throws on invalid base64url', async () => {
const unsealedCore = new AuthKitCore(
{
...mockConfig,
sessionEncoding: { read: 'unsealed', write: 'unsealed' },
} as any,
mockClient as any,
mockEncryption as any,
);

await expect(
unsealedCore.decryptSession('not-valid-json!!!'),
).rejects.toThrow(SessionEncryptionError);
});

it('read:both falls through to unsealed when sealed fails', async () => {
const session = {
accessToken: 'tok',
refreshToken: 'ref',
user: mockUser,
impersonator: undefined,
};
const encoded = Buffer.from(JSON.stringify(session)).toString(
'base64url',
);

const failingEncryption = {
sealData: async () => 'sealed',
unsealData: async () => {
throw new Error('not sealed');
},
};
const bothCore = new AuthKitCore(
{
...mockConfig,
sessionEncoding: { read: 'both', write: 'unsealed' },
} as any,
mockClient as any,
failingEncryption as any,
);

const result = await bothCore.decryptSession(encoded);
expect(result.accessToken).toBe('tok');
});

it('read:both uses sealed when sealed succeeds', async () => {
const bothCore = new AuthKitCore(
{
...mockConfig,
sessionEncoding: { read: 'both', write: 'sealed' },
} as any,
mockClient as any,
mockEncryption as any,
);

// mockEncryption.unsealData returns test-access-token
const result = await bothCore.decryptSession('some-sealed-data');
expect(result.accessToken).toBe('test-access-token');
});

it('read:both throws when both formats fail', async () => {
const failingEncryption = {
sealData: async () => 'sealed',
unsealData: async () => {
throw new Error('not sealed');
},
};
const bothCore = new AuthKitCore(
{
...mockConfig,
sessionEncoding: { read: 'both', write: 'unsealed' },
} as any,
mockClient as any,
failingEncryption as any,
);

await expect(
bothCore.decryptSession('not-valid-b64-json!!!'),
).rejects.toThrow(SessionEncryptionError);
});

it('read:sealed without cookiePassword throws SessionEncryptionError', async () => {
const noPwCore = new AuthKitCore(
{ ...mockConfig, cookiePassword: undefined } as any,
mockClient as any,
mockEncryption as any,
);

await expect(noPwCore.decryptSession('some-data')).rejects.toThrow(
SessionEncryptionError,
);
});
});

describe('refreshTokens()', () => {
Expand Down
82 changes: 72 additions & 10 deletions src/core/AuthKitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No tamper protection on unsealed sessions

decodeUnsealed performs a bare JSON.parse with 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:

  1. Decode the base64url cookie
  2. Modify the user object (email, name, metadata) or inject an impersonator field
  3. Re-encode and submit the tampered cookie

This is especially problematic because validateAndRefresh returns session (the cookie-derived object) directly when the JWT is still valid — and there is no cross-validation between the verified claims.sub and the unverified session.user.id. The caller therefore receives a fully trusted auth.user and auth.impersonator that 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: unsealSession will fail on it (not a valid iron token), and the fallback path in decryptSession will 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 cookiePassword so 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 use claims (the verified JWT payload) exclusively.

Comment on lines +17 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No runtime validation of decoded session shape

JSON.parse(...) as Session is a purely compile-time cast. If the cookie value decodes to null, a non-object, or an object missing required fields like accessToken / user, the downstream code in validateAndRefresh will throw a TypeError (e.g., Cannot read properties of null (reading 'accessToken')) rather than a clean SessionEncryptionError.

Since errors from decryptSession are swallowed in AuthService.withAuth's outer catch block (returning { user: null }), this may be silently tolerable, but surfacing a proper SessionEncryptionError earlier with a useful message would improve debuggability. Consider adding a minimal structural guard:

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.
*
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading