Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
3449cc0
properly capitalize sameSite options to fix Safari's strict cookie is…
nicknisi Nov 10, 2025
7537cf2
infer secure flag from redirectUri if not explicitly set
nicknisi Nov 10, 2025
2c4f03f
fix
nicknisi Nov 13, 2025
4dfdd6a
add AuthKitCore class
nicknisi Nov 13, 2025
a590176
add AuthOperations class
nicknisi Nov 13, 2025
d9b25f4
add AuthService
nicknisi Nov 13, 2025
5179661
update factory
nicknisi Nov 13, 2025
4e9bd5e
wip
nicknisi Nov 14, 2025
95189f7
add tests
nicknisi Nov 14, 2025
b5404f2
convert to toolkit from framework
nicknisi Nov 14, 2025
fafd0fb
update deps
nicknisi Nov 18, 2025
f43ace0
remove old/deprecated code
nicknisi Nov 18, 2025
6e3f0f7
update README
nicknisi Nov 18, 2025
2beddec
chore: bump version to 0.2.0-beta.0
nicknisi Nov 18, 2025
5234159
refactor: reframe AuthService as one orchestration option, not recomm…
nicknisi Nov 19, 2025
c55b00f
formatting
nicknisi Nov 20, 2025
19497e2
remove file
nicknisi Nov 20, 2025
2645f3c
refactor: drop toolkit framing, be honest about architecture
nicknisi Nov 20, 2025
b802f54
remove markdown file
nicknisi Nov 20, 2025
ca7b2fd
formatting
nicknisi Nov 20, 2025
bc369a3
fix build
nicknisi Nov 20, 2025
61ef064
revert: restore original README philosophy and architecture
nicknisi Nov 20, 2025
ea71a0a
formatting
nicknisi Nov 20, 2025
aaa4a1d
clarify terminology and add explanatory comments
nicknisi Nov 24, 2025
2084975
Update src/core/AuthKitCore.ts
nicknisi Nov 24, 2025
881cbd0
update README
nicknisi Nov 24, 2025
ccfb0f6
default isTokenExpiring buffer to 10 rather than 60 (seconds)
nicknisi Dec 1, 2025
cfc802f
turn on erasableSyntaxOnly
nicknisi Dec 1, 2025
5aeef49
pass resolved AuthKitConfig to Core and Operations instead of Configu…
nicknisi Dec 1, 2025
ef86d09
refactor: move lazy initialization from AuthService to factory
nicknisi Dec 1, 2025
207dfad
pass resolved config to CookieSessionStorage
nicknisi Dec 1, 2025
6b98bde
formatting
nicknisi Dec 1, 2025
dd17310
add some claims to top-level for convenience (match authkit-nextjs)
nicknisi Dec 1, 2025
88acab1
refactor: simplify AuthOperations by delegating to core
nicknisi Dec 1, 2025
c6d7ae2
feat: support custom state passthrough in OAuth flow
nicknisi Dec 1, 2025
a9fb59d
docs: clarify encryption is framework's responsibility
nicknisi Dec 1, 2025
ae047f4
account for possibility exp is undefined
nicknisi Dec 1, 2025
596f699
fix: remove proactive token refresh from server-side validation
nicknisi Dec 1, 2025
2b4277b
fix: type errors in tests
nicknisi Dec 1, 2025
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
750 changes: 531 additions & 219 deletions README.md

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@workos/authkit-session",
"version": "0.1.3",
"description": "",
"version": "0.2.0-beta.0",
"description": "Framework-agnostic authentication library for WorkOS with pluggable storage adapters",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down Expand Up @@ -29,16 +29,16 @@
"license": "MIT",
"devDependencies": {
"@types/node": "^20.17.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/coverage-v8": "^4.0.10",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
"typescript": "^5.9.3",
"vitest": "^4.0.10"
},
"dependencies": {
"@workos-inc/node": "^8.0.0-rc.3",
"iron-session": "^8.0.4",
"iron-webcrypto": "^1.2.1",
"jose": "^6.0.12"
"jose": "^6.1.2"
},
"engines": {
"node": ">=20.0.0"
Expand Down
622 changes: 122 additions & 500 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

265 changes: 265 additions & 0 deletions src/core/AuthKitCore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import { AuthKitCore } from './AuthKitCore.js';
import { SessionEncryptionError, TokenRefreshError } from './errors.js';

const mockConfig = {
getValue: (key: string) => {
const values = {
cookiePassword: 'test-password-that-is-32-chars-long!!',
clientId: 'test-client-id',
};
return values[key as keyof typeof values];
},
};

const mockUser = {
id: 'user_123',
email: 'test@example.com',
object: 'user',
firstName: 'Test',
lastName: 'User',
emailVerified: true,
profilePictureUrl: null,
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
lastSignInAt: '2023-01-01T00:00:00Z',
externalId: null,
metadata: {},
} as const;

const mockClient = {
userManagement: {
getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id',
authenticateWithRefreshToken: async () => ({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
user: mockUser,
impersonator: undefined,
}),
},
};

const mockEncryption = {
sealData: async () => 'encrypted-session-data',
unsealData: async () => ({
accessToken: 'test-access-token',
refreshToken: 'test-refresh-token',
user: mockUser,
impersonator: undefined,
}),
};

describe('AuthKitCore', () => {
let core: AuthKitCore;

beforeEach(() => {
core = new AuthKitCore(
mockConfig as any,
mockClient as any,
mockEncryption as any,
);
});

describe('constructor', () => {
it('creates instance with required dependencies', () => {
expect(core).toBeInstanceOf(AuthKitCore);
});
});

describe('parseTokenClaims()', () => {
it('parses valid JWT payload', () => {
const validJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fMTIzIiwiZXhwIjoxNzM2Mzc2MDAwfQ.fake-signature';

const result = core.parseTokenClaims(validJwt);

expect(result.sub).toBe('user_123');
expect(result.sid).toBe('session_123');
expect(result.exp).toBe(1736376000);
});

it('throws error for invalid JWT', () => {
expect(() => core.parseTokenClaims('invalid-jwt')).toThrow(
'Invalid token',
);
});

it('supports custom claims', () => {
const customJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImN1c3RvbUZpZWxkIjoiY3VzdG9tLXZhbHVlIn0.fake-signature';

const result = core.parseTokenClaims<{ customField: string }>(customJwt);

expect(result.customField).toBe('custom-value');
});
});

describe('isTokenExpiring()', () => {
it('returns true when token expires soon', () => {
const soonExpiry = Math.floor(Date.now() / 1000) + 30;
const expiringJwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify({ exp: soonExpiry }))}.fake-signature`;

const result = core.isTokenExpiring(expiringJwt);

expect(result).toBe(true);
});

it('returns false when token expires later', () => {
const laterExpiry = Math.floor(Date.now() / 1000) + 3600;
const validJwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify({ exp: laterExpiry }))}.fake-signature`;

const result = core.isTokenExpiring(validJwt);

expect(result).toBe(false);
});

it('uses custom buffer time', () => {
const expiry = Math.floor(Date.now() / 1000) + 150;
const jwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify({ exp: expiry }))}.fake-signature`;

const result = core.isTokenExpiring(jwt, 180);

expect(result).toBe(true);
});

it('returns false when token has no expiry', () => {
const noExpiryJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.fake-signature';

const result = core.isTokenExpiring(noExpiryJwt);

expect(result).toBe(false);
});
});

describe('verifyToken()', () => {
it('returns false for invalid tokens', async () => {
const result = await core.verifyToken('invalid-token');

expect(result).toBe(false);
});

it('returns false for malformed tokens', async () => {
const result = await core.verifyToken('not.a.jwt');

expect(result).toBe(false);
});
});

describe('encryptSession()', () => {
it('encrypts session data', async () => {
const session = {
accessToken: 'test-token',
refreshToken: 'test-refresh',
user: mockUser,
impersonator: undefined,
};

const result = await core.encryptSession(session);

expect(result).toBe('encrypted-session-data');
});

it('throws SessionEncryptionError on failure', async () => {
const failingEncryption = {
sealData: async () => {
throw new Error('Encryption failed');
},
unsealData: async () => ({}),
};
const failingCore = new AuthKitCore(
mockConfig as any,
mockClient as any,
failingEncryption as any,
);

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

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

expect(result.accessToken).toBe('test-access-token');
expect(result.user).toEqual(mockUser);
});

it('throws SessionEncryptionError on failure', async () => {
const failingEncryption = {
sealData: async () => 'encrypted',
unsealData: async () => {
throw new Error('Decryption failed');
},
};
const failingCore = new AuthKitCore(
mockConfig as any,
mockClient as any,
failingEncryption as any,
);

await expect(failingCore.decryptSession('bad-data')).rejects.toThrow(
SessionEncryptionError,
);
});
});

describe('refreshTokens()', () => {
it('refreshes tokens via WorkOS', async () => {
const result = await core.refreshTokens('refresh-token');

expect(result.accessToken).toBe('new-access-token');
expect(result.refreshToken).toBe('new-refresh-token');
expect(result.user).toEqual(mockUser);
});

it('includes organizationId when provided', async () => {
const clientWithSpy = {
userManagement: {
getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id',
authenticateWithRefreshToken: async ({ organizationId }: any) => ({
accessToken: organizationId ? 'org-token' : 'regular-token',
refreshToken: 'new-refresh-token',
user: mockUser,
impersonator: undefined,
}),
},
};
const testCore = new AuthKitCore(
mockConfig as any,
clientWithSpy as any,
mockEncryption as any,
);

const result = await testCore.refreshTokens('refresh-token', 'org_123');

expect(result.accessToken).toBe('org-token');
});

it('throws TokenRefreshError on failure', async () => {
const failingClient = {
userManagement: {
getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id',
authenticateWithRefreshToken: async () => {
throw new Error('Refresh failed');
},
},
};
const failingCore = new AuthKitCore(
mockConfig as any,
failingClient as any,
mockEncryption as any,
);

await expect(failingCore.refreshTokens('bad-token')).rejects.toThrow(
TokenRefreshError,
);
});
});
});
Loading
Loading