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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,23 @@ const workos = new WorkOS('sk_1234', {
`issuer` also accepts an array when tokens from more than one issuer should be
accepted. When `issuer` is not set, the `iss` claim is not validated.

### Verifying access tokens

To verify an access token your application received elsewhere, for example from
an `Authorization: Bearer` header, use `verifyAccessToken`. It checks the
signature against the WorkOS JWKS and the expiry, plus the issuer when one is
configured, and returns the token's claims, or `null` when the token is invalid:

```ts
const claims = await workos.userManagement.verifyAccessToken(accessToken);

if (!claims) {
// Reject the request.
}
Comment on lines +95 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Example reads rejected claims

The example continues to claims.sub after verifyAccessToken returns null. The comment inside if (!claims) does not stop execution, so the pasted example fails TypeScript's null check and throws for an invalid token. Use a throw or return in that branch.

Suggested change
if (!claims) {
// Reject the request.
}
if (!claims) {
throw new Error('Invalid access token');
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: README.md
Line: 95-97

Comment:
**Example reads rejected claims**

The example continues to `claims.sub` after `verifyAccessToken` returns `null`. The comment inside `if (!claims)` does not stop execution, so the pasted example fails TypeScript's null check and throws for an invalid token. Use a `throw` or `return` in that branch.

```suggestion
if (!claims) {
  throw new Error('Invalid access token');
}
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


console.log(claims.sub, claims.org_id, claims.permissions);
```

## Public Client Mode (Browser/Mobile/CLI)

For apps that can't securely store secrets, initialize with just a client ID:
Expand Down
1 change: 1 addition & 0 deletions src/factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe('createWorkOS', () => {
);
expect(typeof workos.userManagement.getLogoutUrl).toBe('function');
expect(typeof workos.userManagement.getJwksUrl).toBe('function');
expect(typeof workos.userManagement.verifyAccessToken).toBe('function');
});

it('has access to public SSO methods', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ type PublicUserManagementMethods =
| 'authenticateWithCodeAndVerifier'
| 'authenticateWithRefreshToken'
| 'getLogoutUrl'
| 'getJwksUrl';
| 'getJwksUrl'
| 'verifyAccessToken';

/**
* SSO method names available without API key.
Expand Down
24 changes: 24 additions & 0 deletions src/user-management/interfaces/access-token-claims.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { UserManagementAccessToken } from './authenticate-with-session-cookie.interface';

/**
* The claims of a verified WorkOS access token, as returned by
* `userManagement.verifyAccessToken()`.
*
* Combines the registered JWT claims WorkOS sets with the session claims in
* {@link UserManagementAccessToken}. Any other claims present on the token,
* such as custom claims configured for the environment, are available through
* the index signature.
*/
export interface AccessTokenClaims extends UserManagementAccessToken {
/** The ID of the user the token was issued to. */
sub: string;
/** The issuer of the token. */
iss: string;
/** When the token expires, in seconds since the Unix epoch. */
exp: number;
/** When the token was issued, in seconds since the Unix epoch. */
iat: number;
/** When the user last actively authenticated, in seconds since the Unix epoch. */
auth_time?: number;
[claim: string]: unknown;
}
1 change: 1 addition & 0 deletions src/user-management/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './access-token-claims.interface';
export * from './authenticate-with-code-options.interface';
export * from './authenticate-with-code-and-verifier-options.interface';
export * from './authenticate-with-email-verification-options.interface';
Expand Down
87 changes: 87 additions & 0 deletions src/user-management/user-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1870,6 +1870,93 @@ describe('UserManagement', () => {
});
});

describe('verifyAccessToken', () => {
const accessToken = 'eyJhbGciOiJSUzI1NiJ9.e30.signature';

beforeEach(() => {
jest
.mocked(jose.createRemoteJWKSet)
.mockReturnValue(
{} as unknown as ReturnType<typeof jose.createRemoteJWKSet>,
);
});

it('returns the claims of a valid access token', async () => {
const payload = {
iss: 'https://api.workos.com/user_management/proj_123',
sub: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS',
sid: 'session_123',
org_id: 'org_123',
role: 'member',
permissions: ['posts:create'],
exp: 1735684500,
iat: 1735683600,
};
jest
.mocked(jose.jwtVerify)
.mockResolvedValue({ payload } as unknown as jose.JWTVerifyResult &
jose.ResolvedKey);

await expect(
workos.userManagement.verifyAccessToken(accessToken),
).resolves.toEqual(payload);
expect(jose.jwtVerify).toHaveBeenCalledWith(
accessToken,
expect.anything(),
undefined,
);
});

it('enforces the configured issuer', async () => {
const workosWithIssuer = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', {
clientId: 'proj_123',
issuer: 'https://auth.example.com',
});
jest
.mocked(jose.jwtVerify)
.mockResolvedValue({ payload: {} } as unknown as jose.JWTVerifyResult &
jose.ResolvedKey);

await workosWithIssuer.userManagement.verifyAccessToken(accessToken);

expect(jose.jwtVerify).toHaveBeenCalledWith(
accessToken,
expect.anything(),
{ issuer: 'https://auth.example.com' },
);
});

it('returns null when the token fails verification', async () => {
jest.mocked(jose.jwtVerify).mockImplementationOnce(() => {
const error = new Error('"exp" claim timestamp check failed');
(error as Error & { code: string }).code = 'ERR_JWT_EXPIRED';
throw error;
});

await expect(
workos.userManagement.verifyAccessToken(accessToken),
).resolves.toBeNull();
});

it('rethrows errors that are not JWT validation failures', async () => {
jest.mocked(jose.jwtVerify).mockImplementationOnce(() => {
throw new Error('Network error: JWKS fetch failed');
});

await expect(
workos.userManagement.verifyAccessToken(accessToken),
).rejects.toThrow('Network error: JWKS fetch failed');
});

it('throws when no client ID is configured', async () => {
const workosNoClientId = new WorkOS('sk_test_no_client_id');

await expect(
workosNoClientId.userManagement.verifyAccessToken(accessToken),
).rejects.toThrow('Must provide clientId to initialize JWKS');
});
});

describe('getSessionFromCookie', () => {
const OLD_ENV = process.env;

Expand Down
28 changes: 24 additions & 4 deletions src/user-management/user-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ import { serializeCreateWaitlistEntryOptions } from './serializers/create-waitli
import { serializeListWaitlistEntriesOptions } from './serializers/list-waitlist-entries-options.serializer';
import { CookieSession } from './session';
import { getJose } from '../utils/jose';
import { AccessTokenClaims } from './interfaces/access-token-claims.interface';
import { Group, GroupResponse } from '../groups/interfaces';
import { deserializeGroup } from '../groups/serializers';
import { encodePathParameter } from '../common/utils/encode-path-parameter';
Expand Down Expand Up @@ -744,20 +745,35 @@ export class UserManagement {
};
}

private async isValidJwt(accessToken: string): Promise<boolean> {
/**
* Verify an access token and return its claims.
*
* Checks the token's signature against the environment's JWKS and its
* expiry, plus the issuer when one was configured on the client. Resolves to
* `null` when the token fails any of those checks or is not a JWT. Errors
* that are not JWT validation failures, such as a network error while
* fetching the JWKS, propagate.
*
* @param accessToken - The access token to verify, for example from an `Authorization: Bearer` header.
* @returns {Promise<AccessTokenClaims | null>} The token's claims, or `null` when the token is invalid.
* @throws {Error} When no client ID was provided when initializing WorkOS.
*/
async verifyAccessToken(
accessToken: string,
): Promise<AccessTokenClaims | null> {
const jwks = await this.getJWKS();
const { jwtVerify } = await getJose();
if (!jwks) {
throw new Error('Must provide clientId to initialize JWKS');
}

try {
await jwtVerify(
const { payload } = await jwtVerify<AccessTokenClaims>(
accessToken,
jwks,
this.issuer ? { issuer: this.issuer } : undefined,
);
return true;
return payload;
Comment on lines +771 to +776

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Required claims go unchecked

verifyAccessToken returns the payload as AccessTokenClaims without checking that sub, sid, iss, exp, and iat exist. The type argument does not check these fields at runtime. If a signed token for another purpose lacks them, callers receive missing values where TypeScript promises values.

Add a required-claims check before returning, as hasRequiredAgentClaims already does for agent tokens.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/user-management/user-management.ts
Line: 771-776

Comment:
**Required claims go unchecked**

`verifyAccessToken` returns the payload as `AccessTokenClaims` without checking that `sub`, `sid`, `iss`, `exp`, and `iat` exist. The type argument does not check these fields at runtime. If a signed token for another purpose lacks them, callers receive missing values where TypeScript promises values.

Add a required-claims check before returning, as `hasRequiredAgentClaims` already does for agent tokens.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

} catch (e) {
// Only treat as invalid JWT if it's an actual JWT/JWS error from jose
// Network errors, crypto failures, etc. should propagate
Expand All @@ -767,12 +783,16 @@ export class UserManagement {
typeof e.code === 'string' &&
(e.code.startsWith('ERR_JWT_') || e.code.startsWith('ERR_JWS_'))
) {
return false;
return null;
}
throw e;
}
}

private async isValidJwt(accessToken: string): Promise<boolean> {
return (await this.verifyAccessToken(accessToken)) !== null;
}

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