Skip to content
Merged
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
83 changes: 69 additions & 14 deletions src/user-management/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@ jest.mock('jose', () => ({
jwtVerify: jest.fn(),
}));

const verifiedUserAccessToken = {
payload: {
iss: 'https://api.workos.com/user_management/client_123',
sub: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS',
sid: 'session_123',
exp: 1735684500,
iat: 1735683600,
auth_time: 1735683600,
},
} as unknown as jose.JWTVerifyResult & jose.ResolvedKey;

function verifiedAccessToken(accessToken: string) {
return {
...verifiedUserAccessToken,
payload: {
...verifiedUserAccessToken.payload,
...jose.decodeJwt(accessToken),
},
};
}

describe('Session', () => {
let workos: WorkOS;

Expand Down Expand Up @@ -117,16 +138,56 @@ describe('Session', () => {
});
});

it('returns a successful response if the sessionData is valid', async () => {
jest
.mocked(jose.jwtVerify)
.mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey);
it.each([
[
'an agent access token',
{ sub: 'agent_01H5JQDV7R7ATEYZDEG0W5PRYS', sub_profile: 'ai_agent' },
],
['a widget session token', { sub_type: 'authkit' }],
])(
'returns a failed response if the accessToken is %s',
async (_family, familyClaims) => {
jest.mocked(jose.jwtVerify).mockResolvedValue({
...verifiedUserAccessToken,
payload: { ...verifiedUserAccessToken.payload, ...familyClaims },
});

const cookiePassword = 'alongcookiesecretmadefortestingsessions';
const sessionData = await sealData(
{
accessToken: 'abc123',
refreshToken: 'def456',
user: {
object: 'user',
id: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS',
email: 'test@example.com',
},
},
{ password: cookiePassword },
);

const session = workos.userManagement.loadSealedSession({
sessionData,
cookiePassword,
});

await expect(session.authenticate()).resolves.toEqual({
authenticated: false,
reason: 'invalid_jwt',
});
},
);

it('returns a successful response if the sessionData is valid', async () => {
const cookiePassword = 'alongcookiesecretmadefortestingsessions';

const accessToken =
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpbXBlcnNvbmF0b3IiOnsiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInJlYXNvbiI6InRlc3QifSwic2lkIjoic2Vzc2lvbl8xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwicm9sZSI6Im1lbWJlciIsInJvbGVzIjpbIm1lbWJlciIsImFkbWluIl0sInBlcm1pc3Npb25zIjpbInBvc3RzOmNyZWF0ZSIsInBvc3RzOmRlbGV0ZSJdLCJlbnRpdGxlbWVudHMiOlsiYXVkaXQtbG9ncyJdLCJmZWF0dXJlX2ZsYWdzIjpbImRhcmstbW9kZSIsImJldGEtZmVhdHVyZXMiXSwidXNlciI6eyJvYmplY3QiOiJ1c2VyIiwiaWQiOiJ1c2VyXzAxSDVKUURWN1I3QVRFWVpERUcwVzVQUllTIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIn19.TNUzJYn6lzLWFFsiWiKEgIshyUs-bKJQf1VxwNr1cGI';

jest
.mocked(jose.jwtVerify)
.mockResolvedValue(verifiedAccessToken(accessToken));

const sessionData = await sealData(
{
accessToken,
Expand Down Expand Up @@ -202,7 +263,7 @@ describe('Session', () => {
jest
.mocked(jose.jwtVerify)
.mockReset()
.mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey);
.mockResolvedValue(verifiedUserAccessToken);
});

it('does not validate the issuer claim by default', async () => {
Expand Down Expand Up @@ -672,9 +733,7 @@ describe('Session', () => {
refreshToken,
});

jest
.mocked(jose.jwtVerify)
.mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey);
jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken);

const cookiePassword = 'alongcookiesecretmadefortestingsessions';

Expand Down Expand Up @@ -761,9 +820,7 @@ describe('Session', () => {

describe('getLogoutUrl', () => {
it('returns a logout URL for the user', async () => {
jest
.mocked(jose.jwtVerify)
.mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey);
jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken);

const cookiePassword = 'alongcookiesecretmadefortestingsessions';

Expand Down Expand Up @@ -806,9 +863,7 @@ describe('Session', () => {

describe('when a returnTo URL is provided', () => {
it('returns a logout URL for the user', async () => {
jest
.mocked(jose.jwtVerify)
.mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey);
jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken);

const cookiePassword = 'alongcookiesecretmadefortestingsessions';

Expand Down
38 changes: 6 additions & 32 deletions src/user-management/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,17 @@ export class CookieSession {
};
}

if (!(await this.isValidJwt(session.accessToken))) {
const claims = await this.userManagement.verifyAccessToken(
session.accessToken,
);

if (!claims) {
return {
authenticated: false,
reason: AuthenticateWithSessionCookieFailureReason.INVALID_JWT,
};
}

const { decodeJwt } = await getJose();

const {
sid: sessionId,
org_id: organizationId,
Expand All @@ -95,7 +97,7 @@ export class CookieSession {
permissions,
entitlements,
feature_flags: featureFlags,
} = decodeJwt<UserManagementAccessToken>(session.accessToken);
} = claims;

return {
authenticated: true,
Expand Down Expand Up @@ -242,34 +244,6 @@ export class CookieSession {
returnTo,
});
}

private async isValidJwt(accessToken: string): Promise<boolean> {
const { jwtVerify } = await getJose();
const jwks = await this.userManagement.getJWKS();
if (!jwks) {
throw new Error(
'Missing client ID. Did you provide it when initializing WorkOS?',
);
}

const { issuer } = this.userManagement;
try {
await jwtVerify(accessToken, jwks, issuer ? { issuer } : undefined);
return true;
} catch (e) {
// Only treat as invalid JWT if it's an actual JWT/JWS error from jose
// Network errors, crypto failures, etc. should propagate
if (
e instanceof Error &&
'code' in e &&
typeof e.code === 'string' &&
(e.code.startsWith('ERR_JWT_') || e.code.startsWith('ERR_JWS_'))
) {
return false;
}
throw e;
}
}
}

/**
Expand Down
76 changes: 67 additions & 9 deletions src/user-management/user-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ jest.mock('jose', () => ({
createRemoteJWKSet: jest.fn(),
}));

const verifiedUserAccessToken = {
payload: {
iss: 'https://api.workos.com/user_management/client_123',
sub: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS',
sid: 'session_123',
exp: 1735684500,
iat: 1735683600,
auth_time: 1735683600,
},
} as unknown as jose.JWTVerifyResult & jose.ResolvedKey;

function verifiedAccessToken(accessToken: string) {
return {
...verifiedUserAccessToken,
payload: {
...verifiedUserAccessToken.payload,
...jose.decodeJwt(accessToken),
},
};
}

const userId = 'user_01H5JQDV7R7ATEYZDEG0W5PRYS';
const organizationMembershipId = 'om_01H5JQDV7R7ATEYZDEG0W5PRYS';
const emailVerificationId = 'email_verification_01H5JQDV7R7ATEYZDEG0W5PRYS';
Expand Down Expand Up @@ -1614,6 +1635,43 @@ describe('UserManagement', () => {
).resolves.toEqual({ authenticated: false, reason: 'invalid_jwt' });
});

it.each([
[
'an agent access token',
{ sub: 'agent_01H5JQDV7R7ATEYZDEG0W5PRYS', sub_profile: 'ai_agent' },
],
['a widget session token', { sub_type: 'authkit' }],
])(
'returns authenticated = false when the JWT is %s',
async (_family, familyClaims) => {
jest.mocked(jose.jwtVerify).mockResolvedValue({
...verifiedUserAccessToken,
payload: { ...verifiedUserAccessToken.payload, ...familyClaims },
});

const cookiePassword = 'alongcookiesecretmadefortestingsessions';
const sessionData = await sealData(
{
accessToken: 'abc123',
refreshToken: 'def456',
user: {
object: 'user',
id: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS',
email: 'test@example.com',
},
},
{ password: cookiePassword },
);

await expect(
workos.userManagement.authenticateWithSessionCookie({
sessionData,
cookiePassword,
}),
).resolves.toEqual({ authenticated: false, reason: 'invalid_jwt' });
},
);

it('rethrows non-JWT errors (e.g., network failures)', async () => {
jest.mocked(jose.jwtVerify).mockImplementationOnce(() => {
// Simulate a network error (no jose error code)
Expand Down Expand Up @@ -1643,14 +1701,14 @@ describe('UserManagement', () => {
});

it('returns the JWT claims when provided a valid JWT', async () => {
jest
.mocked(jose.jwtVerify)
.mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey);

const cookiePassword = 'alongcookiesecretmadefortestingsessions';
const accessToken =
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpbXBlcnNvbmF0b3IiOnsiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInJlYXNvbiI6InRlc3QifSwic2lkIjoic2Vzc2lvbl8xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwicm9sZSI6Im1lbWJlciIsInBlcm1pc3Npb25zIjpbInBvc3RzOmNyZWF0ZSIsInBvc3RzOmRlbGV0ZSJdLCJlbnRpdGxlbWVudHMiOlsiYXVkaXQtbG9ncyJdLCJmZWF0dXJlX2ZsYWdzIjpbImRhcmstbW9kZSIsImJldGEtZmVhdHVyZXMiXSwidXNlciI6eyJvYmplY3QiOiJ1c2VyIiwiaWQiOiJ1c2VyXzAxSDVKUURWN1I3QVRFWVpERUcwVzVQUllTIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIn19.YVNjR8S2xGn2jAoLuEcBQNJ1_xY3OzjRE1-BK0zjfQE';

jest
.mocked(jose.jwtVerify)
.mockResolvedValue(verifiedAccessToken(accessToken));

const sessionData = await sealData(
{
accessToken,
Expand Down Expand Up @@ -1685,14 +1743,14 @@ describe('UserManagement', () => {
});

it('returns the JWT claims when provided a valid JWT with multiple roles', async () => {
jest
.mocked(jose.jwtVerify)
.mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey);

const cookiePassword = 'alongcookiesecretmadefortestingsessions';
const accessToken =
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpbXBlcnNvbmF0b3IiOnsiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInJlYXNvbiI6InRlc3QifSwic2lkIjoic2Vzc2lvbl8xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwicm9sZSI6ImFkbWluIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwicGVybWlzc2lvbnMiOlsicG9zdHM6Y3JlYXRlIiwicG9zdHM6ZGVsZXRlIl0sImVudGl0bGVtZW50cyI6WyJhdWRpdC1sb2dzIl0sImZlYXR1cmVfZmxhZ3MiOlsiZGFyay1tb2RlIiwiYmV0YS1mZWF0dXJlcyJdLCJ1c2VyIjp7Im9iamVjdCI6InVzZXIiLCJpZCI6InVzZXJfMDFINUpRRFY3UjdBVEVZWkRFRzBXNVBSWVMiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifX0.hsMptIB7PmbF5pxxtgTtCdUyOAhA11ZIAP-JY5zU5fE';

jest
.mocked(jose.jwtVerify)
.mockResolvedValue(verifiedAccessToken(accessToken));

const sessionData = await sealData(
{
accessToken,
Expand Down Expand Up @@ -1752,7 +1810,7 @@ describe('UserManagement', () => {
jest
.mocked(jose.jwtVerify)
.mockReset()
.mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey);
.mockResolvedValue(verifiedUserAccessToken);
});

it('does not validate the issuer claim by default', async () => {
Expand Down
18 changes: 4 additions & 14 deletions src/user-management/user-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,14 +689,6 @@ export class UserManagement {
throw new Error('Cookie password is required');
}

const jwks = await this.getJWKS();

if (!jwks) {
throw new Error('Must provide clientId to initialize JWKS');
}

const { decodeJwt } = await getJose();

if (!sessionData) {
return {
authenticated: false,
Expand All @@ -719,7 +711,9 @@ export class UserManagement {
};
}

if (!(await this.isValidJwt(session.accessToken))) {
const claims = await this.verifyAccessToken(session.accessToken);

if (!claims) {
return {
authenticated: false,
reason: AuthenticateWithSessionCookieFailureReason.INVALID_JWT,
Expand All @@ -734,7 +728,7 @@ export class UserManagement {
permissions,
entitlements,
feature_flags: featureFlags,
} = decodeJwt<UserManagementAccessToken>(session.accessToken);
} = claims;

return {
authenticated: true,
Expand Down Expand Up @@ -867,10 +861,6 @@ export class UserManagement {
);
}

private async isValidJwt(accessToken: string): Promise<boolean> {
return (await this.verifyJwt(accessToken)) !== null;
Comment thread
m0tzy marked this conversation as resolved.
Resolved
}

private async prepareAuthenticationResponse({
authenticationResponse,
session,
Expand Down
Loading