Skip to content
Open
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
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,16 +164,17 @@ auth.claims.sid; // string

## Configuration Options

| Environment Variable | Config Key | Description |
| ------------------------- | ---------------- | ------------------------------------ |
| `WORKOS_CLIENT_ID` | `clientId` | WorkOS client ID |
| `WORKOS_API_KEY` | `apiKey` | WorkOS API key |
| `WORKOS_REDIRECT_URI` | `redirectUri` | OAuth callback URL |
| `WORKOS_COOKIE_PASSWORD` | `cookiePassword` | 32+ char encryption key |
| `WORKOS_COOKIE_NAME` | `cookieName` | Cookie name (default: `wos-session`) |
| `WORKOS_COOKIE_MAX_AGE` | `cookieMaxAge` | Cookie lifetime in seconds |
| `WORKOS_COOKIE_DOMAIN` | `cookieDomain` | Cookie domain |
| `WORKOS_COOKIE_SAME_SITE` | `cookieSameSite` | `lax`, `strict`, or `none` |
| Environment Variable | Config Key | Description |
| ------------------------- | ---------------- | --------------------------------------------------------------------------------------------------- |
| `WORKOS_CLIENT_ID` | `clientId` | WorkOS client ID |
| `WORKOS_API_KEY` | `apiKey` | WorkOS API key |
| `WORKOS_REDIRECT_URI` | `redirectUri` | OAuth callback URL |
| `WORKOS_COOKIE_PASSWORD` | `cookiePassword` | 32+ char encryption key |
| `WORKOS_COOKIE_NAME` | `cookieName` | Cookie name (default: `wos-session`) |
| `WORKOS_COOKIE_MAX_AGE` | `cookieMaxAge` | Cookie lifetime in seconds |
| `WORKOS_COOKIE_DOMAIN` | `cookieDomain` | Cookie domain |
| `WORKOS_COOKIE_SAME_SITE` | `cookieSameSite` | `lax`, `strict`, or `none` |
| `WORKOS_ISSUER` | `issuer` | Expected `iss` claim of access tokens, comma-separated to accept several (not validated when unset) |

Environment variables override programmatic config.

Expand Down
97 changes: 97 additions & 0 deletions src/core/AuthKitCore.issuer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { jwtVerify } from 'jose';
import { AuthKitCore } from './AuthKitCore.js';

vi.mock('jose', async importOriginal => {
const actual = await importOriginal<typeof import('jose')>();
return {
...actual,
jwtVerify: vi.fn().mockResolvedValue({ payload: {}, protectedHeader: {} }),
};
});

const mockClient = {
userManagement: {
getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id',
},
};

const mockEncryption = {
sealData: async () => 'encrypted-session-data',
unsealData: async () => ({}),
};

const baseConfig = {
cookiePassword: 'test-password-that-is-32-chars-long!!',
clientId: 'test-client-id',
};

function createCore(config: Record<string, unknown>) {
return new AuthKitCore(
config as any,
mockClient as any,
mockEncryption as any,
);
}

describe('AuthKitCore.verifyToken() issuer validation', () => {
beforeEach(() => {
vi.mocked(jwtVerify).mockClear();
});

it('does not validate the issuer claim by default', async () => {
const core = createCore(baseConfig);

await expect(core.verifyToken('some.jwt.token')).resolves.toBe(true);

expect(jwtVerify).toHaveBeenCalledTimes(1);
expect(jwtVerify).toHaveBeenCalledWith(
'some.jwt.token',
expect.any(Function),
undefined,
);
});

it('validates the issuer claim when issuer is configured', async () => {
const core = createCore({
...baseConfig,
issuer: 'https://auth.example.com',
});

await expect(core.verifyToken('some.jwt.token')).resolves.toBe(true);

expect(jwtVerify).toHaveBeenCalledTimes(1);
expect(jwtVerify).toHaveBeenCalledWith(
'some.jwt.token',
expect.any(Function),
{ issuer: 'https://auth.example.com' },
);
});

it('passes a list of issuers through to jwtVerify', async () => {
const issuer = [
'https://auth.example.com',
'https://api.workos.com/user_management/test-client-id',
];
const core = createCore({ ...baseConfig, issuer });

await expect(core.verifyToken('some.jwt.token')).resolves.toBe(true);

expect(jwtVerify).toHaveBeenCalledWith(
'some.jwt.token',
expect.any(Function),
{ issuer },
);
});

it('returns false when jwtVerify rejects the issuer', async () => {
vi.mocked(jwtVerify).mockRejectedValueOnce(
new Error('unexpected "iss" claim value'),
);
const core = createCore({
...baseConfig,
issuer: 'https://auth.example.com',
});

await expect(core.verifyToken('some.jwt.token')).resolves.toBe(false);
});
});
8 changes: 7 additions & 1 deletion src/core/AuthKitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,19 @@ export class AuthKitCore {

/**
* Verify a JWT access token against WorkOS JWKS.
* The `iss` claim is only validated when `config.issuer` is set.
*
* @param token - The JWT access token to verify
* @returns true if valid, false otherwise
*/
async verifyToken(token: string): Promise<boolean> {
const issuer = this.config.issuer;
try {
await jwtVerify(token, this.getPublicKey());
await jwtVerify(
token,
this.getPublicKey(),
issuer ? { issuer } : undefined,
);
return true;
} catch {
return false;
Expand Down
47 changes: 47 additions & 0 deletions src/core/config/ConfigurationProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,31 @@ describe('ConfigurationProvider', () => {

expect(provider.getValue('apiPort')).toBeUndefined();
});

it('parses a comma-separated issuer into a list', () => {
const source = vi
.fn()
.mockReturnValue('https://a.example.com, https://b.example.com,');
provider.configure(source);

expect(provider.getValue('issuer')).toEqual([
'https://a.example.com',
'https://b.example.com',
]);
});

it('keeps a single issuer as a string', () => {
const source = vi.fn().mockReturnValue('https://a.example.com');
provider.configure(source);

expect(provider.getValue('issuer')).toBe('https://a.example.com');
});

it('does not split a programmatic issuer string', () => {
provider.configure({ issuer: 'https://a.example.com/path,a' });

expect(provider.getValue('issuer')).toBe('https://a.example.com/path,a');
});
});

describe('getEnvironmentVariableName()', () => {
Expand Down Expand Up @@ -118,6 +143,28 @@ describe('ConfigurationProvider', () => {
const config = provider.getConfig();
expect(config.cookieName).toBe('test-cookie');
});

it('includes optional keys that are only set in the value source', () => {
const validPassword = 'a'.repeat(32);
provider.configure(
{
clientId: 'test-client',
apiKey: 'test-api-key',
redirectUri: 'http://localhost:3000/callback',
cookiePassword: validPassword,
},
key =>
key === 'WORKOS_ISSUER'
? 'https://a.example.com,https://b.example.com'
: undefined,
);

const config = provider.getConfig();
expect(config.issuer).toEqual([
'https://a.example.com',
'https://b.example.com',
]);
});
});

describe('validate()', () => {
Expand Down
25 changes: 24 additions & 1 deletion src/core/config/ConfigurationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ export class ConfigurationProvider {
'cookiePassword',
];

// Optional keys with no default; only resolvable from the value source
private readonly optionalKeys: (keyof AuthKitConfig)[] = [
'apiPort',
'issuer',
'cookieSameSite',
'cookieDomain',
];

/**
* Convert a camelCase string to an uppercase, underscore-separated environment variable name.
* @param str The string to convert
Expand Down Expand Up @@ -79,7 +87,11 @@ export class ConfigurationProvider {
const rawValue = envValue ?? this.config[key];

if (rawValue != null) {
return this.convertValueType(key, rawValue) as AuthKitConfig[K];
return this.convertValueType(
key,
rawValue,
envValue != null,
) as AuthKitConfig[K];
}

if (this.requiredKeys.includes(key)) {
Expand Down Expand Up @@ -108,6 +120,7 @@ export class ConfigurationProvider {
private convertValueType<K extends keyof AuthKitConfig>(
key: K,
value: unknown,
fromEnvironment = false,
): AuthKitConfig[K] | undefined {
if (typeof value !== 'string') {
return value as AuthKitConfig[K];
Expand All @@ -124,6 +137,15 @@ export class ConfigurationProvider {
return (isNaN(num) ? undefined : num) as AuthKitConfig[K];
}

// Handle comma-separated issuer lists (environment values only)
if (key === 'issuer' && fromEnvironment) {
const issuers = value
.split(',')
.map(issuer => issuer.trim())
.filter(Boolean);
return (issuers.length <= 1 ? issuers[0] : issuers) as AuthKitConfig[K];
}

return value as AuthKitConfig[K];
}

Expand Down Expand Up @@ -184,6 +206,7 @@ export class ConfigurationProvider {
const allKeys = new Set<keyof AuthKitConfig>([
...(Object.keys(this.config) as (keyof AuthKitConfig)[]),
...this.requiredKeys,
...this.optionalKeys,
]);

// Merge each key, with environment variables taking precedence
Expand Down
7 changes: 7 additions & 0 deletions src/core/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ export interface AuthKitConfig {
*/
apiPort?: number;

/**
* The expected `iss` claim of WorkOS access tokens, or a list of accepted issuers
* Equivalent to the WORKOS_ISSUER environment variable (comma-separated for a list)
* When not set, the issuer claim is not validated
*/
issuer?: string | string[];

/**
* The maximum age of the session cookie in seconds
* Equivalent to the WORKOS_COOKIE_MAX_AGE environment variable
Expand Down
Loading