From c2f40199afdc95bb492da06da9a21e2379627f4f Mon Sep 17 00:00:00 2001 From: Madison Packer Date: Wed, 24 Jun 2026 15:09:49 -0700 Subject: [PATCH 1/7] feat: Initial agent registration methods --- src/agents/agents.spec.ts | 95 ++++++++++++++++ src/agents/agents.ts | 58 ++++++++++ .../fixtures/get-agent-registration.json | 21 ++++ .../fixtures/validate-agent-credential.json | 5 + .../agent-registration.interface.ts | 106 ++++++++++++++++++ src/agents/interfaces/index.ts | 2 + .../validate-agent-credential.interface.ts | 36 ++++++ .../agent-registration.serializer.ts | 40 +++++++ src/agents/serializers/index.ts | 2 + .../validate-agent-credential.serializer.ts | 25 +++++ src/index.ts | 1 + src/workos.ts | 2 + 12 files changed, 393 insertions(+) create mode 100644 src/agents/agents.spec.ts create mode 100644 src/agents/agents.ts create mode 100644 src/agents/fixtures/get-agent-registration.json create mode 100644 src/agents/fixtures/validate-agent-credential.json create mode 100644 src/agents/interfaces/agent-registration.interface.ts create mode 100644 src/agents/interfaces/index.ts create mode 100644 src/agents/interfaces/validate-agent-credential.interface.ts create mode 100644 src/agents/serializers/agent-registration.serializer.ts create mode 100644 src/agents/serializers/index.ts create mode 100644 src/agents/serializers/validate-agent-credential.serializer.ts diff --git a/src/agents/agents.spec.ts b/src/agents/agents.spec.ts new file mode 100644 index 000000000..dc3fc1607 --- /dev/null +++ b/src/agents/agents.spec.ts @@ -0,0 +1,95 @@ +import fetch from 'jest-fetch-mock'; +import { fetchBody, fetchOnce, fetchURL } from '../common/utils/test-utils'; +import { WorkOS } from '../workos'; +import getAgentRegistrationFixture from './fixtures/get-agent-registration.json'; +import validateAgentCredentialFixture from './fixtures/validate-agent-credential.json'; + +describe('Agents', () => { + let workos: WorkOS; + + beforeAll(() => { + workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { + apiHostname: 'api.workos.test', + clientId: 'proj_123', + }); + }); + + beforeEach(() => fetch.resetMocks()); + + describe('getRegistration', () => { + it('retrieves an agent registration', async () => { + fetchOnce(getAgentRegistrationFixture); + + const registration = await workos.agents.getRegistration( + 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + ); + + expect(fetchURL()).toContain( + '/agents/registrations/agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + ); + expect(registration).toEqual({ + id: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + agentIdentity: { + id: 'agent_identity_01EHZNVPK3SFK441A1RGBFSHRT', + userlandUserId: null, + createdAt: '2023-07-18T02:07:19.911Z', + updatedAt: '2023-07-18T02:07:19.911Z', + }, + organizationId: 'org_01EHZNVPK3SFK441A1RGBFSHRT', + status: 'unverified', + kind: 'anonymous', + claim: { + id: 'agent_registration_claim_01EHZNVPK3SFK441A1RGBFSHRT', + claimCompletion: null, + createdAt: '2023-07-18T02:07:19.911Z', + updatedAt: '2023-07-18T02:07:19.911Z', + expiresAt: '2099-01-01T00:00:00.000Z', + }, + createdAt: '2023-07-18T02:07:19.911Z', + updatedAt: '2023-07-18T02:07:19.911Z', + }); + }); + }); + + describe('validateCredential', () => { + it('validates an agent credential', async () => { + fetchOnce(validateAgentCredentialFixture); + + const validation = await workos.agents.validateCredential({ + type: 'api_key', + credential: 'sk_example', + }); + + expect(fetchURL()).toContain('/agents/credentials/validate'); + expect(fetchBody()).toEqual({ + type: 'api_key', + credential: 'sk_example', + }); + expect(validation).toEqual({ + valid: true, + registrationId: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + expiresAt: '2099-01-01T00:00:00.000Z', + }); + }); + + it('reports an invalid credential', async () => { + fetchOnce({ + valid: false, + registration_id: null, + expires_at: null, + }); + + const validation = await workos.agents.validateCredential({ + type: 'access_token', + credential: 'invalid', + }); + + expect(fetchURL()).toContain('/agents/credentials/validate'); + expect(validation).toEqual({ + valid: false, + registrationId: null, + expiresAt: null, + }); + }); + }); +}); diff --git a/src/agents/agents.ts b/src/agents/agents.ts new file mode 100644 index 000000000..bb7190d9f --- /dev/null +++ b/src/agents/agents.ts @@ -0,0 +1,58 @@ +import { WorkOS } from '../workos'; +import { + AgentCredentialValidation, + AgentRegistration, + SerializedAgentCredentialValidation, + SerializedAgentRegistration, + ValidateAgentCredentialOptions, +} from './interfaces'; +import { + deserializeAgentCredentialValidation, + deserializeAgentRegistration, + serializeValidateAgentCredentialOptions, +} from './serializers'; + +export class Agents { + constructor(private readonly workos: WorkOS) {} + + /** + * Get an agent registration + * + * Retrieve a single agent registration scoped to the API key's environment. + * @param id - Unique identifier of the agent registration. + * + * @example + * "agent_registration_01EHZNVPK3SFK441A1RGBFSHRT" + * + * @returns {Promise} + * @throws {NotFoundException} 404 + */ + async getRegistration(id: string): Promise { + const { data } = await this.workos.get( + `/agents/registrations/${id}`, + ); + + return deserializeAgentRegistration(data); + } + + /** + * Validate an agent credential + * + * Validate an agent credential (`api_key` or `access_token`) against the API + * key's environment. This is a read-only check — it never consumes or mutates + * the credential. + * @param options - Object containing the credential type and value. + * @returns {Promise} + */ + async validateCredential( + options: ValidateAgentCredentialOptions, + ): Promise { + const { data } = + await this.workos.post( + '/agents/credentials/validate', + serializeValidateAgentCredentialOptions(options), + ); + + return deserializeAgentCredentialValidation(data); + } +} diff --git a/src/agents/fixtures/get-agent-registration.json b/src/agents/fixtures/get-agent-registration.json new file mode 100644 index 000000000..31dcdf216 --- /dev/null +++ b/src/agents/fixtures/get-agent-registration.json @@ -0,0 +1,21 @@ +{ + "id": "agent_registration_01EHZNVPK3SFK441A1RGBFSHRT", + "agent_identity": { + "id": "agent_identity_01EHZNVPK3SFK441A1RGBFSHRT", + "userland_user_id": null, + "created_at": "2023-07-18T02:07:19.911Z", + "updated_at": "2023-07-18T02:07:19.911Z" + }, + "organization_id": "org_01EHZNVPK3SFK441A1RGBFSHRT", + "status": "unverified", + "kind": "anonymous", + "claim": { + "id": "agent_registration_claim_01EHZNVPK3SFK441A1RGBFSHRT", + "claim_completion": null, + "created_at": "2023-07-18T02:07:19.911Z", + "updated_at": "2023-07-18T02:07:19.911Z", + "expires_at": "2099-01-01T00:00:00.000Z" + }, + "created_at": "2023-07-18T02:07:19.911Z", + "updated_at": "2023-07-18T02:07:19.911Z" +} diff --git a/src/agents/fixtures/validate-agent-credential.json b/src/agents/fixtures/validate-agent-credential.json new file mode 100644 index 000000000..4133fda66 --- /dev/null +++ b/src/agents/fixtures/validate-agent-credential.json @@ -0,0 +1,5 @@ +{ + "valid": true, + "registration_id": "agent_registration_01EHZNVPK3SFK441A1RGBFSHRT", + "expires_at": "2099-01-01T00:00:00.000Z" +} diff --git a/src/agents/interfaces/agent-registration.interface.ts b/src/agents/interfaces/agent-registration.interface.ts new file mode 100644 index 000000000..3a29d8347 --- /dev/null +++ b/src/agents/interfaces/agent-registration.interface.ts @@ -0,0 +1,106 @@ +/** The lifecycle status of an agent registration. */ +export type AgentRegistrationStatus = + | 'unverified' + | 'verified' + | 'expired' + | 'revoked'; + +/** The kind of agent registration, derived from its authentication method. */ +export type AgentRegistrationKind = + | 'anonymous' + | 'service_auth' + | 'identity_assertion'; + +/** The agent identity an agent registration belongs to. */ +export interface AgentIdentity { + /** Unique identifier of the agent identity. */ + id: string; + /** The Userland user the agent identity is associated with, if any. */ + userlandUserId: string | null; + /** An ISO 8601 timestamp. */ + createdAt: string; + /** An ISO 8601 timestamp. */ + updatedAt: string; +} + +export interface SerializedAgentIdentity { + id: string; + userland_user_id: string | null; + created_at: string; + updated_at: string; +} + +/** The completion of an agent registration claim. */ +export interface AgentRegistrationClaimCompletion { + /** Unique identifier of the claim completion. */ + id: string; + /** An ISO 8601 timestamp. */ + createdAt: string; + /** An ISO 8601 timestamp. */ + updatedAt: string; + /** An ISO 8601 timestamp. */ + expiresAt: string; + /** An ISO 8601 timestamp of when the registration was claimed. */ + claimedAt: string; +} + +export interface SerializedAgentRegistrationClaimCompletion { + id: string; + created_at: string; + updated_at: string; + expires_at: string; + claimed_at: string; +} + +/** The claim state of an agent registration. */ +export interface AgentRegistrationClaim { + /** Unique identifier of the claim. */ + id: string; + /** The completion of the claim, or `null` if it has not been claimed. */ + claimCompletion: AgentRegistrationClaimCompletion | null; + /** An ISO 8601 timestamp. */ + createdAt: string; + /** An ISO 8601 timestamp. */ + updatedAt: string; + /** An ISO 8601 timestamp. */ + expiresAt: string; +} + +export interface SerializedAgentRegistrationClaim { + id: string; + claim_completion: SerializedAgentRegistrationClaimCompletion | null; + created_at: string; + updated_at: string; + expires_at: string; +} + +/** A single agent registration. */ +export interface AgentRegistration { + /** Unique identifier of the agent registration. */ + id: string; + /** The agent identity the registration belongs to. */ + agentIdentity: AgentIdentity; + /** Unique identifier of the Organization the registration belongs to. */ + organizationId: string; + /** The lifecycle status of the registration. */ + status: AgentRegistrationStatus; + /** The kind of registration. */ + kind: AgentRegistrationKind; + /** The claim state of the registration, or `null` if it has none. */ + claim: AgentRegistrationClaim | null; + /** An ISO 8601 timestamp. */ + createdAt: string; + /** An ISO 8601 timestamp. */ + updatedAt: string; +} + +export interface SerializedAgentRegistration { + id: string; + agent_identity: SerializedAgentIdentity; + organization_id: string; + status: AgentRegistrationStatus; + kind: AgentRegistrationKind; + claim: SerializedAgentRegistrationClaim | null; + created_at: string; + updated_at: string; +} diff --git a/src/agents/interfaces/index.ts b/src/agents/interfaces/index.ts new file mode 100644 index 000000000..87ee656f1 --- /dev/null +++ b/src/agents/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './agent-registration.interface'; +export * from './validate-agent-credential.interface'; diff --git a/src/agents/interfaces/validate-agent-credential.interface.ts b/src/agents/interfaces/validate-agent-credential.interface.ts new file mode 100644 index 000000000..427ef4f7a --- /dev/null +++ b/src/agents/interfaces/validate-agent-credential.interface.ts @@ -0,0 +1,36 @@ +/** The type of agent credential to validate. */ +export type AgentCredentialType = 'api_key' | 'access_token'; + +export interface ValidateAgentCredentialOptions { + /** The type of credential being validated. */ + type: AgentCredentialType; + /** The opaque credential value to validate. */ + credential: string; +} + +export interface SerializedValidateAgentCredentialOptions { + type: AgentCredentialType; + credential: string; +} + +/** The result of validating an agent credential. */ +export interface AgentCredentialValidation { + /** Whether the credential is valid. */ + valid: boolean; + /** + * Unique identifier of the agent registration the credential was issued for, + * or `null` when the credential is invalid. + */ + registrationId: string | null; + /** + * An ISO 8601 timestamp of when the credential expires, or `null` when it + * does not expire or is invalid. + */ + expiresAt: string | null; +} + +export interface SerializedAgentCredentialValidation { + valid: boolean; + registration_id: string | null; + expires_at: string | null; +} diff --git a/src/agents/serializers/agent-registration.serializer.ts b/src/agents/serializers/agent-registration.serializer.ts new file mode 100644 index 000000000..4e480f2c7 --- /dev/null +++ b/src/agents/serializers/agent-registration.serializer.ts @@ -0,0 +1,40 @@ +import { + AgentRegistration, + SerializedAgentRegistration, +} from '../interfaces/agent-registration.interface'; + +export function deserializeAgentRegistration( + registration: SerializedAgentRegistration, +): AgentRegistration { + return { + id: registration.id, + agentIdentity: { + id: registration.agent_identity.id, + userlandUserId: registration.agent_identity.userland_user_id, + createdAt: registration.agent_identity.created_at, + updatedAt: registration.agent_identity.updated_at, + }, + organizationId: registration.organization_id, + status: registration.status, + kind: registration.kind, + claim: registration.claim + ? { + id: registration.claim.id, + claimCompletion: registration.claim.claim_completion + ? { + id: registration.claim.claim_completion.id, + createdAt: registration.claim.claim_completion.created_at, + updatedAt: registration.claim.claim_completion.updated_at, + expiresAt: registration.claim.claim_completion.expires_at, + claimedAt: registration.claim.claim_completion.claimed_at, + } + : null, + createdAt: registration.claim.created_at, + updatedAt: registration.claim.updated_at, + expiresAt: registration.claim.expires_at, + } + : null, + createdAt: registration.created_at, + updatedAt: registration.updated_at, + }; +} diff --git a/src/agents/serializers/index.ts b/src/agents/serializers/index.ts new file mode 100644 index 000000000..0c2a6f604 --- /dev/null +++ b/src/agents/serializers/index.ts @@ -0,0 +1,2 @@ +export * from './agent-registration.serializer'; +export * from './validate-agent-credential.serializer'; diff --git a/src/agents/serializers/validate-agent-credential.serializer.ts b/src/agents/serializers/validate-agent-credential.serializer.ts new file mode 100644 index 000000000..966130fc5 --- /dev/null +++ b/src/agents/serializers/validate-agent-credential.serializer.ts @@ -0,0 +1,25 @@ +import { + AgentCredentialValidation, + SerializedAgentCredentialValidation, + SerializedValidateAgentCredentialOptions, + ValidateAgentCredentialOptions, +} from '../interfaces/validate-agent-credential.interface'; + +export function serializeValidateAgentCredentialOptions( + options: ValidateAgentCredentialOptions, +): SerializedValidateAgentCredentialOptions { + return { + type: options.type, + credential: options.credential, + }; +} + +export function deserializeAgentCredentialValidation( + validation: SerializedAgentCredentialValidation, +): AgentCredentialValidation { + return { + valid: validation.valid, + registrationId: validation.registration_id, + expiresAt: validation.expires_at, + }; +} diff --git a/src/index.ts b/src/index.ts index b5619a9d7..d3e4bb370 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { WorkOS } from './workos'; import { WorkOSOptions } from './common/interfaces'; export * from './actions/interfaces'; +export * from './agents/interfaces'; export * from './api-keys/interfaces'; export * from './audit-logs/interfaces'; export * from './authorization/interfaces'; diff --git a/src/workos.ts b/src/workos.ts index 19fbc3182..6bceec035 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -20,6 +20,7 @@ import { WorkOSOptions, WorkOSResponseError, } from './common/interfaces'; +import { Agents } from './agents/agents'; import { ApiKeys } from './api-keys/api-keys'; import { Connect } from './connect/connect'; import { DirectorySync } from './directory-sync/directory-sync'; @@ -69,6 +70,7 @@ export class WorkOS { private readonly hasApiKey: boolean; readonly actions: Actions; + readonly agents = new Agents(this); readonly apiKeys = new ApiKeys(this); readonly auditLogs = new AuditLogs(this); readonly authorization = new Authorization(this); From 41fbeb3070139905f2a5c2f4be6c477b255856dc Mon Sep 17 00:00:00 2001 From: Madison Packer Date: Wed, 24 Jun 2026 17:51:23 -0700 Subject: [PATCH 2/7] feat: Decode agent access tokens locally with optional revocation check validateCredential now verifies access_token credentials against the environment's cached JWKS and returns the decoded claims without a network request. A new checkForRevoked option (access_token only) additionally calls the server to confirm the token has not been revoked. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agents/agents.spec.ts | 174 +++++++++++++++--- src/agents/agents.ts | 111 ++++++++++- .../validate-agent-credential.interface.ts | 68 ++++++- .../validate-agent-credential.serializer.ts | 19 ++ 4 files changed, 337 insertions(+), 35 deletions(-) diff --git a/src/agents/agents.spec.ts b/src/agents/agents.spec.ts index dc3fc1607..5adff5996 100644 --- a/src/agents/agents.spec.ts +++ b/src/agents/agents.spec.ts @@ -1,9 +1,38 @@ +import * as jose from 'jose'; import fetch from 'jest-fetch-mock'; import { fetchBody, fetchOnce, fetchURL } from '../common/utils/test-utils'; import { WorkOS } from '../workos'; import getAgentRegistrationFixture from './fixtures/get-agent-registration.json'; import validateAgentCredentialFixture from './fixtures/validate-agent-credential.json'; +jest.mock('jose', () => ({ + ...jest.requireActual('jose'), + jwtVerify: jest.fn(), +})); + +const ACCESS_TOKEN_PAYLOAD = { + iss: 'https://auth.example.com', + aud: 'proj_123', + sub: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + jti: '01EHZNVPK3SFK441A1RGBFSHRT', + organization_id: 'org_01EHZNVPK3SFK441A1RGBFSHRT', + scope: 'read write', + exp: 4102444800, // 2100-01-01T00:00:00Z + iat: 1689646039, +}; + +const EXPECTED_CLAIMS = { + issuer: 'https://auth.example.com', + audience: 'proj_123', + registrationId: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + jwtId: '01EHZNVPK3SFK441A1RGBFSHRT', + organizationId: 'org_01EHZNVPK3SFK441A1RGBFSHRT', + scope: 'read write', + actor: undefined, + expiresAt: 4102444800, + issuedAt: 1689646039, +}; + describe('Agents', () => { let workos: WorkOS; @@ -14,7 +43,10 @@ describe('Agents', () => { }); }); - beforeEach(() => fetch.resetMocks()); + beforeEach(() => { + fetch.resetMocks(); + jest.mocked(jose.jwtVerify).mockReset(); + }); describe('getRegistration', () => { it('retrieves an agent registration', async () => { @@ -52,43 +84,129 @@ describe('Agents', () => { }); describe('validateCredential', () => { - it('validates an agent credential', async () => { - fetchOnce(validateAgentCredentialFixture); + describe('api_key', () => { + it('validates the key against the server', async () => { + fetchOnce(validateAgentCredentialFixture); - const validation = await workos.agents.validateCredential({ - type: 'api_key', - credential: 'sk_example', - }); + const validation = await workos.agents.validateCredential({ + type: 'api_key', + credential: 'sk_example', + }); - expect(fetchURL()).toContain('/agents/credentials/validate'); - expect(fetchBody()).toEqual({ - type: 'api_key', - credential: 'sk_example', + expect(fetchURL()).toContain('/agents/credentials/validate'); + expect(fetchBody()).toEqual({ + type: 'api_key', + credential: 'sk_example', + }); + expect(validation).toEqual({ + valid: true, + registrationId: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + expiresAt: '2099-01-01T00:00:00.000Z', + claims: null, + }); }); - expect(validation).toEqual({ - valid: true, - registrationId: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', - expiresAt: '2099-01-01T00:00:00.000Z', + + it('reports an invalid key', async () => { + fetchOnce({ valid: false, registration_id: null, expires_at: null }); + + const validation = await workos.agents.validateCredential({ + type: 'api_key', + credential: 'sk_invalid', + }); + + expect(validation).toEqual({ + valid: false, + registrationId: null, + expiresAt: null, + claims: null, + }); }); }); - it('reports an invalid credential', async () => { - fetchOnce({ - valid: false, - registration_id: null, - expires_at: null, + describe('access_token', () => { + it('decodes and verifies the token locally without a network request', async () => { + jest + .mocked(jose.jwtVerify) + .mockResolvedValue({ payload: ACCESS_TOKEN_PAYLOAD } as never); + + const validation = await workos.agents.validateCredential({ + type: 'access_token', + credential: 'eyJ.token.value', + }); + + expect(fetch).not.toHaveBeenCalled(); + expect(validation).toEqual({ + valid: true, + registrationId: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + expiresAt: '2100-01-01T00:00:00.000Z', + claims: EXPECTED_CLAIMS, + }); + }); + + it('reports an invalid token when verification fails', async () => { + jest.mocked(jose.jwtVerify).mockImplementation(() => { + const error = new Error('expired') as Error & { code: string }; + error.code = 'ERR_JWT_EXPIRED'; + throw error; + }); + + const validation = await workos.agents.validateCredential({ + type: 'access_token', + credential: 'eyJ.expired.token', + }); + + expect(fetch).not.toHaveBeenCalled(); + expect(validation).toEqual({ + valid: false, + registrationId: null, + expiresAt: null, + claims: null, + }); }); - const validation = await workos.agents.validateCredential({ - type: 'access_token', - credential: 'invalid', + it('checks the server for revocation when checkForRevoked is set', async () => { + jest + .mocked(jose.jwtVerify) + .mockResolvedValue({ payload: ACCESS_TOKEN_PAYLOAD } as never); + fetchOnce(validateAgentCredentialFixture); + + const validation = await workos.agents.validateCredential({ + type: 'access_token', + credential: 'eyJ.token.value', + checkForRevoked: true, + }); + + expect(fetchURL()).toContain('/agents/credentials/validate'); + expect(fetchBody()).toEqual({ + type: 'access_token', + credential: 'eyJ.token.value', + }); + expect(validation).toEqual({ + valid: true, + registrationId: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + expiresAt: '2099-01-01T00:00:00.000Z', + claims: EXPECTED_CLAIMS, + }); }); - expect(fetchURL()).toContain('/agents/credentials/validate'); - expect(validation).toEqual({ - valid: false, - registrationId: null, - expiresAt: null, + it('reports a revoked token as invalid and drops its claims', async () => { + jest + .mocked(jose.jwtVerify) + .mockResolvedValue({ payload: ACCESS_TOKEN_PAYLOAD } as never); + fetchOnce({ valid: false, registration_id: null, expires_at: null }); + + const validation = await workos.agents.validateCredential({ + type: 'access_token', + credential: 'eyJ.revoked.token', + checkForRevoked: true, + }); + + expect(validation).toEqual({ + valid: false, + registrationId: null, + expiresAt: null, + claims: null, + }); }); }); }); diff --git a/src/agents/agents.ts b/src/agents/agents.ts index bb7190d9f..5c90ea093 100644 --- a/src/agents/agents.ts +++ b/src/agents/agents.ts @@ -1,18 +1,24 @@ +import { getJose } from '../utils/jose'; import { WorkOS } from '../workos'; import { AgentCredentialValidation, AgentRegistration, + SerializedAgentAccessTokenClaims, SerializedAgentCredentialValidation, SerializedAgentRegistration, + ValidateAgentAccessTokenOptions, ValidateAgentCredentialOptions, } from './interfaces'; import { + deserializeAgentAccessTokenClaims, deserializeAgentCredentialValidation, deserializeAgentRegistration, serializeValidateAgentCredentialOptions, } from './serializers'; export class Agents { + private _jwks?: ReturnType; + constructor(private readonly workos: WorkOS) {} /** @@ -38,14 +44,65 @@ export class Agents { /** * Validate an agent credential * - * Validate an agent credential (`api_key` or `access_token`) against the API - * key's environment. This is a read-only check — it never consumes or mutates - * the credential. + * For `access_token` credentials, the token is decoded and verified locally + * against the environment's JWKS and its claims are returned — no network + * request is made unless `checkForRevoked` is set, in which case the WorkOS + * API is also called to confirm the token has not been revoked. + * + * For `api_key` credentials, the WorkOS API is always called to validate the + * key against the environment. + * * @param options - Object containing the credential type and value. * @returns {Promise} */ async validateCredential( options: ValidateAgentCredentialOptions, + ): Promise { + if (options.type === 'access_token') { + return this.validateAccessToken(options); + } + + return this.validateCredentialRemotely(options); + } + + private async validateAccessToken( + options: ValidateAgentAccessTokenOptions, + ): Promise { + const claims = await this.verifyAccessTokenClaims(options.credential); + + if (!claims) { + return { + valid: false, + registrationId: null, + expiresAt: null, + claims: null, + }; + } + + // The signature and time claims check out locally. Unless the caller wants + // a revocation check, that's the whole verdict — a revoked but unexpired + // token still reports valid here. + if (!options.checkForRevoked) { + return { + valid: true, + registrationId: claims.registrationId, + expiresAt: + claims.expiresAt != null + ? new Date(claims.expiresAt * 1000).toISOString() + : null, + claims, + }; + } + + // Confirm against the server that the token has not been revoked. The + // server is the source of truth for revocation and expiry, but the locally + // decoded claims are still surfaced. + const remote = await this.validateCredentialRemotely(options); + return { ...remote, claims: remote.valid ? claims : null }; + } + + private async validateCredentialRemotely( + options: ValidateAgentCredentialOptions, ): Promise { const { data } = await this.workos.post( @@ -55,4 +112,52 @@ export class Agents { return deserializeAgentCredentialValidation(data); } + + /** + * Verifies an access token's signature and time claims against the + * environment's JWKS and returns its decoded claims, or `null` when the token + * is invalid (bad signature, expired, malformed). Errors that are not JWT + * validation failures (e.g. network errors fetching the JWKS) propagate. + */ + private async verifyAccessTokenClaims(credential: string) { + const { jwtVerify } = await getJose(); + const jwks = await this.getJWKS(); + + try { + const { payload } = await jwtVerify( + credential, + jwks, + ); + return deserializeAgentAccessTokenClaims(payload); + } catch (e) { + if ( + e instanceof Error && + 'code' in e && + typeof e.code === 'string' && + (e.code.startsWith('ERR_JWT_') || e.code.startsWith('ERR_JWS_')) + ) { + return null; + } + throw e; + } + } + + private async getJWKS(): Promise< + ReturnType + > { + const { clientId } = this.workos; + if (!clientId) { + throw new Error( + 'Missing client ID. Did you provide it when initializing WorkOS?', + ); + } + + const { createRemoteJWKSet } = await getJose(); + this._jwks ??= createRemoteJWKSet( + new URL(`${this.workos.baseURL}/sso/jwks/${clientId}`), + { cooldownDuration: 1000 * 60 * 5 }, + ); + + return this._jwks; + } } diff --git a/src/agents/interfaces/validate-agent-credential.interface.ts b/src/agents/interfaces/validate-agent-credential.interface.ts index 427ef4f7a..31a70fa40 100644 --- a/src/agents/interfaces/validate-agent-credential.interface.ts +++ b/src/agents/interfaces/validate-agent-credential.interface.ts @@ -1,18 +1,73 @@ /** The type of agent credential to validate. */ export type AgentCredentialType = 'api_key' | 'access_token'; -export interface ValidateAgentCredentialOptions { - /** The type of credential being validated. */ - type: AgentCredentialType; - /** The opaque credential value to validate. */ +export interface ValidateAgentApiKeyOptions { + type: 'api_key'; + /** The opaque API key value to validate. */ credential: string; } +export interface ValidateAgentAccessTokenOptions { + type: 'access_token'; + /** The access token (JWT) to validate. */ + credential: string; + /** + * When `true`, additionally calls the WorkOS API to check whether the token + * has been revoked. When `false` or omitted, the token is only decoded and + * verified locally against the environment's JWKS — a revoked but + * not-yet-expired token will still report as valid. + */ + checkForRevoked?: boolean; +} + +/** + * Options for validating an agent credential. `checkForRevoked` is only + * available for `access_token` credentials. + */ +export type ValidateAgentCredentialOptions = + | ValidateAgentApiKeyOptions + | ValidateAgentAccessTokenOptions; + export interface SerializedValidateAgentCredentialOptions { type: AgentCredentialType; credential: string; } +/** The decoded claims of an agent access token. */ +export interface AgentAccessTokenClaims { + /** The token issuer (`iss`). */ + issuer?: string; + /** The token audience (`aud`). */ + audience?: string | string[]; + /** Unique identifier of the agent registration the token was issued for (`sub`). */ + registrationId: string; + /** The token's unique identifier (`jti`). */ + jwtId: string; + /** Unique identifier of the Organization the registration belongs to. */ + organizationId: string; + /** The space-separated scopes granted to the token, if any (`scope`). */ + scope?: string; + /** The actor the token acts on behalf of, if any (`act`). */ + actor?: { sub: string }; + /** The time the token expires, in seconds since the epoch (`exp`). */ + expiresAt?: number; + /** The time the token was issued, in seconds since the epoch (`iat`). */ + issuedAt?: number; +} + +/** The raw JWT payload of an agent access token, as encoded in the token. */ +export interface SerializedAgentAccessTokenClaims { + iss?: string; + aud?: string | string[]; + sub?: string; + jti?: string; + organization_id?: string; + scope?: string; + act?: { sub: string }; + exp?: number; + iat?: number; +} + /** The result of validating an agent credential. */ export interface AgentCredentialValidation { /** Whether the credential is valid. */ @@ -27,6 +82,11 @@ export interface AgentCredentialValidation { * does not expire or is invalid. */ expiresAt: string | null; + /** + * The decoded claims of the access token. Only populated for valid + * `access_token` credentials; `null` for API keys and invalid tokens. + */ + claims: AgentAccessTokenClaims | null; } export interface SerializedAgentCredentialValidation { diff --git a/src/agents/serializers/validate-agent-credential.serializer.ts b/src/agents/serializers/validate-agent-credential.serializer.ts index 966130fc5..2e520c65e 100644 --- a/src/agents/serializers/validate-agent-credential.serializer.ts +++ b/src/agents/serializers/validate-agent-credential.serializer.ts @@ -1,5 +1,7 @@ import { + AgentAccessTokenClaims, AgentCredentialValidation, + SerializedAgentAccessTokenClaims, SerializedAgentCredentialValidation, SerializedValidateAgentCredentialOptions, ValidateAgentCredentialOptions, @@ -21,5 +23,22 @@ export function deserializeAgentCredentialValidation( valid: validation.valid, registrationId: validation.registration_id, expiresAt: validation.expires_at, + claims: null, + }; +} + +export function deserializeAgentAccessTokenClaims( + payload: SerializedAgentAccessTokenClaims, +): AgentAccessTokenClaims { + return { + issuer: payload.iss, + audience: payload.aud, + registrationId: payload.sub ?? '', + jwtId: payload.jti ?? '', + organizationId: payload.organization_id ?? '', + scope: payload.scope, + actor: payload.act, + expiresAt: payload.exp, + issuedAt: payload.iat, }; } From e16ce8a621bdb94c22329bdd9b4662291749a36d Mon Sep 17 00:00:00 2001 From: Madison Packer Date: Wed, 24 Jun 2026 17:54:56 -0700 Subject: [PATCH 3/7] feat: Enforce access token audience and return a discriminated validation result Local access_token verification now enforces the token audience (defaulting to the configured client ID, with an `audience` override for resource-scoped tokens whose aud is the resource). The validation result is now a discriminated union on `valid`, so a valid result narrows registrationId to a non-null string. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agents/agents.spec.ts | 46 +++++++++++++++++++ src/agents/agents.ts | 31 +++++++++---- .../validate-agent-credential.interface.ts | 44 ++++++++++++------ .../validate-agent-credential.serializer.ts | 13 +++++- 4 files changed, 109 insertions(+), 25 deletions(-) diff --git a/src/agents/agents.spec.ts b/src/agents/agents.spec.ts index 5adff5996..8a34b031b 100644 --- a/src/agents/agents.spec.ts +++ b/src/agents/agents.spec.ts @@ -135,6 +135,12 @@ describe('Agents', () => { }); expect(fetch).not.toHaveBeenCalled(); + // Audience defaults to the configured client ID. + expect(jose.jwtVerify).toHaveBeenCalledWith( + 'eyJ.token.value', + expect.anything(), + { audience: 'proj_123' }, + ); expect(validation).toEqual({ valid: true, registrationId: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', @@ -143,6 +149,46 @@ describe('Agents', () => { }); }); + it('verifies against a caller-supplied audience for resource-scoped tokens', async () => { + jest + .mocked(jose.jwtVerify) + .mockResolvedValue({ payload: ACCESS_TOKEN_PAYLOAD } as never); + + await workos.agents.validateCredential({ + type: 'access_token', + credential: 'eyJ.token.value', + audience: 'https://api.example.com', + }); + + expect(jose.jwtVerify).toHaveBeenCalledWith( + 'eyJ.token.value', + expect.anything(), + { audience: 'https://api.example.com' }, + ); + }); + + it('reports an invalid token when the audience does not match', async () => { + jest.mocked(jose.jwtVerify).mockImplementation(() => { + const error = new Error('audience mismatch') as Error & { + code: string; + }; + error.code = 'ERR_JWT_CLAIM_VALIDATION_FAILED'; + throw error; + }); + + const validation = await workos.agents.validateCredential({ + type: 'access_token', + credential: 'eyJ.wrong.audience', + }); + + expect(validation).toEqual({ + valid: false, + registrationId: null, + expiresAt: null, + claims: null, + }); + }); + it('reports an invalid token when verification fails', async () => { jest.mocked(jose.jwtVerify).mockImplementation(() => { const error = new Error('expired') as Error & { code: string }; diff --git a/src/agents/agents.ts b/src/agents/agents.ts index 5c90ea093..e2ca7b846 100644 --- a/src/agents/agents.ts +++ b/src/agents/agents.ts @@ -68,7 +68,10 @@ export class Agents { private async validateAccessToken( options: ValidateAgentAccessTokenOptions, ): Promise { - const claims = await this.verifyAccessTokenClaims(options.credential); + const claims = await this.verifyAccessTokenClaims( + options.credential, + options.audience, + ); if (!claims) { return { @@ -79,9 +82,9 @@ export class Agents { }; } - // The signature and time claims check out locally. Unless the caller wants - // a revocation check, that's the whole verdict — a revoked but unexpired - // token still reports valid here. + // The signature, audience, and time claims check out locally. Unless the + // caller wants a revocation check, that's the whole verdict — a revoked but + // unexpired token still reports valid here. if (!options.checkForRevoked) { return { valid: true, @@ -98,7 +101,7 @@ export class Agents { // server is the source of truth for revocation and expiry, but the locally // decoded claims are still surfaced. const remote = await this.validateCredentialRemotely(options); - return { ...remote, claims: remote.valid ? claims : null }; + return remote.valid ? { ...remote, claims } : remote; } private async validateCredentialRemotely( @@ -114,19 +117,29 @@ export class Agents { } /** - * Verifies an access token's signature and time claims against the + * Verifies an access token's signature, audience, and time claims against the * environment's JWKS and returns its decoded claims, or `null` when the token - * is invalid (bad signature, expired, malformed). Errors that are not JWT - * validation failures (e.g. network errors fetching the JWKS) propagate. + * is invalid (bad signature, wrong audience, expired, malformed). Errors that + * are not JWT validation failures (e.g. network errors fetching the JWKS) + * propagate. + * + * The audience defaults to the client ID; resource-scoped tokens carry the + * resource as their audience and require it to be passed explicitly. */ - private async verifyAccessTokenClaims(credential: string) { + private async verifyAccessTokenClaims( + credential: string, + audience?: string | string[], + ) { const { jwtVerify } = await getJose(); + // Throws when no client ID is configured, so `this.workos.clientId` below + // is guaranteed to be present as the default audience. const jwks = await this.getJWKS(); try { const { payload } = await jwtVerify( credential, jwks, + { audience: audience ?? this.workos.clientId }, ); return deserializeAgentAccessTokenClaims(payload); } catch (e) { diff --git a/src/agents/interfaces/validate-agent-credential.interface.ts b/src/agents/interfaces/validate-agent-credential.interface.ts index 31a70fa40..11c2316f7 100644 --- a/src/agents/interfaces/validate-agent-credential.interface.ts +++ b/src/agents/interfaces/validate-agent-credential.interface.ts @@ -18,11 +18,18 @@ export interface ValidateAgentAccessTokenOptions { * not-yet-expired token will still report as valid. */ checkForRevoked?: boolean; + /** + * The expected token audience (`aud`). Defaults to the client ID the WorkOS + * client was initialized with. Pass the resource indicator for + * resource-scoped tokens, whose audience is the resource rather than the + * client ID. + */ + audience?: string | string[]; } /** - * Options for validating an agent credential. `checkForRevoked` is only - * available for `access_token` credentials. + * Options for validating an agent credential. `checkForRevoked` and `audience` + * are only available for `access_token` credentials. */ export type ValidateAgentCredentialOptions = | ValidateAgentApiKeyOptions @@ -68,27 +75,36 @@ export interface SerializedAgentAccessTokenClaims { iat?: number; } -/** The result of validating an agent credential. */ -export interface AgentCredentialValidation { - /** Whether the credential is valid. */ - valid: boolean; - /** - * Unique identifier of the agent registration the credential was issued for, - * or `null` when the credential is invalid. - */ - registrationId: string | null; +/** A valid agent credential. */ +export interface ValidAgentCredential { + valid: true; + /** Unique identifier of the agent registration the credential was issued for. */ + registrationId: string; /** * An ISO 8601 timestamp of when the credential expires, or `null` when it - * does not expire or is invalid. + * does not expire. */ expiresAt: string | null; /** - * The decoded claims of the access token. Only populated for valid - * `access_token` credentials; `null` for API keys and invalid tokens. + * The decoded claims of the access token. Populated for `access_token` + * credentials; `null` for API keys. */ claims: AgentAccessTokenClaims | null; } +/** An invalid agent credential. */ +export interface InvalidAgentCredential { + valid: false; + registrationId: null; + expiresAt: null; + claims: null; +} + +/** The result of validating an agent credential. */ +export type AgentCredentialValidation = + | ValidAgentCredential + | InvalidAgentCredential; + export interface SerializedAgentCredentialValidation { valid: boolean; registration_id: string | null; diff --git a/src/agents/serializers/validate-agent-credential.serializer.ts b/src/agents/serializers/validate-agent-credential.serializer.ts index 2e520c65e..ea1045c6c 100644 --- a/src/agents/serializers/validate-agent-credential.serializer.ts +++ b/src/agents/serializers/validate-agent-credential.serializer.ts @@ -19,9 +19,18 @@ export function serializeValidateAgentCredentialOptions( export function deserializeAgentCredentialValidation( validation: SerializedAgentCredentialValidation, ): AgentCredentialValidation { + if (!validation.valid) { + return { + valid: false, + registrationId: null, + expiresAt: null, + claims: null, + }; + } + return { - valid: validation.valid, - registrationId: validation.registration_id, + valid: true, + registrationId: validation.registration_id ?? '', expiresAt: validation.expires_at, claims: null, }; From 2455c7c0e9e8d93e0b713a3cbfec1abeacfb6274 Mon Sep 17 00:00:00 2001 From: Madison Packer Date: Wed, 24 Jun 2026 19:41:11 -0700 Subject: [PATCH 4/7] feat: Forward access token audience to the validation endpoint The validate endpoint now accepts an optional audience on the access_token variant, so checkForRevoked requests forward the same audience the SDK verifies against locally, letting the server verify the JWT aud claim too. The audience option is narrowed to a single string to match the endpoint's contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agents/agents.spec.ts | 20 +++++++++++++++++++ src/agents/agents.ts | 5 +---- .../validate-agent-credential.interface.ts | 6 ++++-- .../validate-agent-credential.serializer.ts | 4 ++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/agents/agents.spec.ts b/src/agents/agents.spec.ts index 8a34b031b..7d15c5513 100644 --- a/src/agents/agents.spec.ts +++ b/src/agents/agents.spec.ts @@ -235,6 +235,26 @@ describe('Agents', () => { }); }); + it('forwards the audience to the server when checking for revocation', async () => { + jest + .mocked(jose.jwtVerify) + .mockResolvedValue({ payload: ACCESS_TOKEN_PAYLOAD } as never); + fetchOnce(validateAgentCredentialFixture); + + await workos.agents.validateCredential({ + type: 'access_token', + credential: 'eyJ.token.value', + checkForRevoked: true, + audience: 'https://api.example.com', + }); + + expect(fetchBody()).toEqual({ + type: 'access_token', + credential: 'eyJ.token.value', + audience: 'https://api.example.com', + }); + }); + it('reports a revoked token as invalid and drops its claims', async () => { jest .mocked(jose.jwtVerify) diff --git a/src/agents/agents.ts b/src/agents/agents.ts index e2ca7b846..ad49192c0 100644 --- a/src/agents/agents.ts +++ b/src/agents/agents.ts @@ -126,10 +126,7 @@ export class Agents { * The audience defaults to the client ID; resource-scoped tokens carry the * resource as their audience and require it to be passed explicitly. */ - private async verifyAccessTokenClaims( - credential: string, - audience?: string | string[], - ) { + private async verifyAccessTokenClaims(credential: string, audience?: string) { const { jwtVerify } = await getJose(); // Throws when no client ID is configured, so `this.workos.clientId` below // is guaranteed to be present as the default audience. diff --git a/src/agents/interfaces/validate-agent-credential.interface.ts b/src/agents/interfaces/validate-agent-credential.interface.ts index 11c2316f7..dd60b05e9 100644 --- a/src/agents/interfaces/validate-agent-credential.interface.ts +++ b/src/agents/interfaces/validate-agent-credential.interface.ts @@ -22,9 +22,10 @@ export interface ValidateAgentAccessTokenOptions { * The expected token audience (`aud`). Defaults to the client ID the WorkOS * client was initialized with. Pass the resource indicator for * resource-scoped tokens, whose audience is the resource rather than the - * client ID. + * client ID. When `checkForRevoked` is set, this is also forwarded to the + * WorkOS API so the server verifies the `aud` claim against the same value. */ - audience?: string | string[]; + audience?: string; } /** @@ -38,6 +39,7 @@ export type ValidateAgentCredentialOptions = export interface SerializedValidateAgentCredentialOptions { type: AgentCredentialType; credential: string; + audience?: string; } /** The decoded claims of an agent access token. */ diff --git a/src/agents/serializers/validate-agent-credential.serializer.ts b/src/agents/serializers/validate-agent-credential.serializer.ts index ea1045c6c..a9922d3dc 100644 --- a/src/agents/serializers/validate-agent-credential.serializer.ts +++ b/src/agents/serializers/validate-agent-credential.serializer.ts @@ -13,6 +13,10 @@ export function serializeValidateAgentCredentialOptions( return { type: options.type, credential: options.credential, + // Only access_token credentials carry an audience; forwarding it lets the + // server verify the JWT `aud` claim against the same value. + ...(options.type === 'access_token' && + options.audience !== undefined && { audience: options.audience }), }; } From cf686ad90e20beadc3c290d27ad83c7c5c6fc824 Mon Sep 17 00:00:00 2001 From: Madison Packer Date: Thu, 25 Jun 2026 07:31:19 -0700 Subject: [PATCH 5/7] fix(agents): Correct entity id prefixes in examples and fixtures Agent registration ids use the agent_reg_ prefix (not agent_registration_), claims use agent_reg_claim_, and claim attempts use agent_reg_claim_attempt_. Also exercise the non-null claim_completion path in the getRegistration test. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agents/agents.spec.ts | 26 ++++++++++++------- src/agents/agents.ts | 2 +- .../fixtures/get-agent-registration.json | 12 ++++++--- .../fixtures/validate-agent-credential.json | 2 +- 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/agents/agents.spec.ts b/src/agents/agents.spec.ts index 7d15c5513..5922ca1c7 100644 --- a/src/agents/agents.spec.ts +++ b/src/agents/agents.spec.ts @@ -13,7 +13,7 @@ jest.mock('jose', () => ({ const ACCESS_TOKEN_PAYLOAD = { iss: 'https://auth.example.com', aud: 'proj_123', - sub: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + sub: 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', jti: '01EHZNVPK3SFK441A1RGBFSHRT', organization_id: 'org_01EHZNVPK3SFK441A1RGBFSHRT', scope: 'read write', @@ -24,7 +24,7 @@ const ACCESS_TOKEN_PAYLOAD = { const EXPECTED_CLAIMS = { issuer: 'https://auth.example.com', audience: 'proj_123', - registrationId: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + registrationId: 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', jwtId: '01EHZNVPK3SFK441A1RGBFSHRT', organizationId: 'org_01EHZNVPK3SFK441A1RGBFSHRT', scope: 'read write', @@ -53,14 +53,14 @@ describe('Agents', () => { fetchOnce(getAgentRegistrationFixture); const registration = await workos.agents.getRegistration( - 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', ); expect(fetchURL()).toContain( - '/agents/registrations/agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + '/agents/registrations/agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', ); expect(registration).toEqual({ - id: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + id: 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', agentIdentity: { id: 'agent_identity_01EHZNVPK3SFK441A1RGBFSHRT', userlandUserId: null, @@ -71,8 +71,14 @@ describe('Agents', () => { status: 'unverified', kind: 'anonymous', claim: { - id: 'agent_registration_claim_01EHZNVPK3SFK441A1RGBFSHRT', - claimCompletion: null, + id: 'agent_reg_claim_01EHZNVPK3SFK441A1RGBFSHRT', + claimCompletion: { + id: 'agent_reg_claim_attempt_01EHZNVPK3SFK441A1RGBFSHRT', + createdAt: '2023-07-18T02:07:19.911Z', + updatedAt: '2023-07-18T02:07:19.911Z', + expiresAt: '2099-01-01T00:00:00.000Z', + claimedAt: '2023-07-18T02:08:00.000Z', + }, createdAt: '2023-07-18T02:07:19.911Z', updatedAt: '2023-07-18T02:07:19.911Z', expiresAt: '2099-01-01T00:00:00.000Z', @@ -100,7 +106,7 @@ describe('Agents', () => { }); expect(validation).toEqual({ valid: true, - registrationId: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + registrationId: 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', expiresAt: '2099-01-01T00:00:00.000Z', claims: null, }); @@ -143,7 +149,7 @@ describe('Agents', () => { ); expect(validation).toEqual({ valid: true, - registrationId: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + registrationId: 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', expiresAt: '2100-01-01T00:00:00.000Z', claims: EXPECTED_CLAIMS, }); @@ -229,7 +235,7 @@ describe('Agents', () => { }); expect(validation).toEqual({ valid: true, - registrationId: 'agent_registration_01EHZNVPK3SFK441A1RGBFSHRT', + registrationId: 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', expiresAt: '2099-01-01T00:00:00.000Z', claims: EXPECTED_CLAIMS, }); diff --git a/src/agents/agents.ts b/src/agents/agents.ts index ad49192c0..1e9f30793 100644 --- a/src/agents/agents.ts +++ b/src/agents/agents.ts @@ -28,7 +28,7 @@ export class Agents { * @param id - Unique identifier of the agent registration. * * @example - * "agent_registration_01EHZNVPK3SFK441A1RGBFSHRT" + * "agent_reg_01EHZNVPK3SFK441A1RGBFSHRT" * * @returns {Promise} * @throws {NotFoundException} 404 diff --git a/src/agents/fixtures/get-agent-registration.json b/src/agents/fixtures/get-agent-registration.json index 31dcdf216..4c405ff7c 100644 --- a/src/agents/fixtures/get-agent-registration.json +++ b/src/agents/fixtures/get-agent-registration.json @@ -1,5 +1,5 @@ { - "id": "agent_registration_01EHZNVPK3SFK441A1RGBFSHRT", + "id": "agent_reg_01EHZNVPK3SFK441A1RGBFSHRT", "agent_identity": { "id": "agent_identity_01EHZNVPK3SFK441A1RGBFSHRT", "userland_user_id": null, @@ -10,8 +10,14 @@ "status": "unverified", "kind": "anonymous", "claim": { - "id": "agent_registration_claim_01EHZNVPK3SFK441A1RGBFSHRT", - "claim_completion": null, + "id": "agent_reg_claim_01EHZNVPK3SFK441A1RGBFSHRT", + "claim_completion": { + "id": "agent_reg_claim_attempt_01EHZNVPK3SFK441A1RGBFSHRT", + "created_at": "2023-07-18T02:07:19.911Z", + "updated_at": "2023-07-18T02:07:19.911Z", + "expires_at": "2099-01-01T00:00:00.000Z", + "claimed_at": "2023-07-18T02:08:00.000Z" + }, "created_at": "2023-07-18T02:07:19.911Z", "updated_at": "2023-07-18T02:07:19.911Z", "expires_at": "2099-01-01T00:00:00.000Z" diff --git a/src/agents/fixtures/validate-agent-credential.json b/src/agents/fixtures/validate-agent-credential.json index 4133fda66..409a3f5bc 100644 --- a/src/agents/fixtures/validate-agent-credential.json +++ b/src/agents/fixtures/validate-agent-credential.json @@ -1,5 +1,5 @@ { "valid": true, - "registration_id": "agent_registration_01EHZNVPK3SFK441A1RGBFSHRT", + "registration_id": "agent_reg_01EHZNVPK3SFK441A1RGBFSHRT", "expires_at": "2099-01-01T00:00:00.000Z" } From 86dece095de304c240db3e4534e26044465453d2 Mon Sep 17 00:00:00 2001 From: Madison Packer Date: Fri, 26 Jun 2026 07:33:56 -0700 Subject: [PATCH 6/7] fix(agents): Address review feedback on credential validation - Reject access tokens missing the agent identity claims (sub/jti/ organization_id) instead of reporting them valid with empty identifiers. - Treat a valid server verdict with a null registration_id as invalid. - URL-encode the registration id in the getRegistration request path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agents/agents.spec.ts | 49 +++++++++++++++++++ src/agents/agents.ts | 17 +++++-- .../validate-agent-credential.serializer.ts | 6 ++- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/agents/agents.spec.ts b/src/agents/agents.spec.ts index 5922ca1c7..19bd86a87 100644 --- a/src/agents/agents.spec.ts +++ b/src/agents/agents.spec.ts @@ -87,6 +87,16 @@ describe('Agents', () => { updatedAt: '2023-07-18T02:07:19.911Z', }); }); + + it('encodes the registration id in the request path', async () => { + fetchOnce(getAgentRegistrationFixture); + + await workos.agents.getRegistration('agent_reg/../../evil'); + + expect(fetchURL()).toContain( + '/agents/registrations/agent_reg%2F..%2F..%2Fevil', + ); + }); }); describe('validateCredential', () => { @@ -127,6 +137,24 @@ describe('Agents', () => { claims: null, }); }); + + it('reports invalid when the server omits the registration id', async () => { + // Defensive: a valid verdict with no registration must not surface an + // empty registration id to callers. + fetchOnce({ valid: true, registration_id: null, expires_at: null }); + + const validation = await workos.agents.validateCredential({ + type: 'api_key', + credential: 'sk_example', + }); + + expect(validation).toEqual({ + valid: false, + registrationId: null, + expiresAt: null, + claims: null, + }); + }); }); describe('access_token', () => { @@ -195,6 +223,27 @@ describe('Agents', () => { }); }); + it('reports invalid for a token missing the agent identity claims', async () => { + // A token signed by the same JWKS with the right audience but without + // the agent claims (sub/jti/organization_id) is not an agent credential. + const { sub, ...withoutSub } = ACCESS_TOKEN_PAYLOAD; + jest + .mocked(jose.jwtVerify) + .mockResolvedValue({ payload: withoutSub } as never); + + const validation = await workos.agents.validateCredential({ + type: 'access_token', + credential: 'eyJ.no.agent.claims', + }); + + expect(validation).toEqual({ + valid: false, + registrationId: null, + expiresAt: null, + claims: null, + }); + }); + it('reports an invalid token when verification fails', async () => { jest.mocked(jose.jwtVerify).mockImplementation(() => { const error = new Error('expired') as Error & { code: string }; diff --git a/src/agents/agents.ts b/src/agents/agents.ts index 1e9f30793..ac763678a 100644 --- a/src/agents/agents.ts +++ b/src/agents/agents.ts @@ -35,7 +35,7 @@ export class Agents { */ async getRegistration(id: string): Promise { const { data } = await this.workos.get( - `/agents/registrations/${id}`, + `/agents/registrations/${encodeURIComponent(id)}`, ); return deserializeAgentRegistration(data); @@ -119,9 +119,9 @@ export class Agents { /** * Verifies an access token's signature, audience, and time claims against the * environment's JWKS and returns its decoded claims, or `null` when the token - * is invalid (bad signature, wrong audience, expired, malformed). Errors that - * are not JWT validation failures (e.g. network errors fetching the JWKS) - * propagate. + * is invalid (bad signature, wrong audience, expired, malformed, or missing + * the agent identity claims). Errors that are not JWT validation failures + * (e.g. network errors fetching the JWKS) propagate. * * The audience defaults to the client ID; resource-scoped tokens carry the * resource as their audience and require it to be passed explicitly. @@ -138,6 +138,15 @@ export class Agents { jwks, { audience: audience ?? this.workos.clientId }, ); + + // A well-formed agent token always carries these claims. A token signed + // by the same JWKS for a different purpose (e.g. a user session) is not a + // valid agent credential, so reject it rather than report it valid with + // empty identifiers. + if (!payload.sub || !payload.jti || !payload.organization_id) { + return null; + } + return deserializeAgentAccessTokenClaims(payload); } catch (e) { if ( diff --git a/src/agents/serializers/validate-agent-credential.serializer.ts b/src/agents/serializers/validate-agent-credential.serializer.ts index a9922d3dc..6e0642b77 100644 --- a/src/agents/serializers/validate-agent-credential.serializer.ts +++ b/src/agents/serializers/validate-agent-credential.serializer.ts @@ -23,7 +23,9 @@ export function serializeValidateAgentCredentialOptions( export function deserializeAgentCredentialValidation( validation: SerializedAgentCredentialValidation, ): AgentCredentialValidation { - if (!validation.valid) { + // A valid credential is always bound to a registration; treat a missing + // registration id as invalid rather than surfacing an empty identifier. + if (!validation.valid || validation.registration_id == null) { return { valid: false, registrationId: null, @@ -34,7 +36,7 @@ export function deserializeAgentCredentialValidation( return { valid: true, - registrationId: validation.registration_id ?? '', + registrationId: validation.registration_id, expiresAt: validation.expires_at, claims: null, }; From 2401fc5d58a825298c659d9439b804f4f2e231c2 Mon Sep 17 00:00:00 2001 From: Madison Packer Date: Fri, 26 Jun 2026 14:08:10 -0700 Subject: [PATCH 7/7] refactor(agents): Require core access token claims and reject past expiry Mark iss/aud/exp/iat (and sub/jti/organization_id) required on SerializedAgentAccessTokenClaims and guarantee them via a single hasRequiredAgentClaims guard, so decoded claims never surface empty values. Also reject a token whose exp is in the past explicitly, and keep the raw jti claim name instead of mapping it to jwtId. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agents/agents.spec.ts | 22 ++++++++- src/agents/agents.ts | 45 +++++++++++++------ .../validate-agent-credential.interface.ts | 39 ++++++++++------ .../validate-agent-credential.serializer.ts | 6 +-- 4 files changed, 80 insertions(+), 32 deletions(-) diff --git a/src/agents/agents.spec.ts b/src/agents/agents.spec.ts index 19bd86a87..f96c69710 100644 --- a/src/agents/agents.spec.ts +++ b/src/agents/agents.spec.ts @@ -25,7 +25,7 @@ const EXPECTED_CLAIMS = { issuer: 'https://auth.example.com', audience: 'proj_123', registrationId: 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', - jwtId: '01EHZNVPK3SFK441A1RGBFSHRT', + jti: '01EHZNVPK3SFK441A1RGBFSHRT', organizationId: 'org_01EHZNVPK3SFK441A1RGBFSHRT', scope: 'read write', actor: undefined, @@ -244,6 +244,26 @@ describe('Agents', () => { }); }); + it('reports invalid for a token whose expiry is in the past', async () => { + // jose is mocked here, so this exercises the SDK's own past-expiry + // guard rather than jose's built-in exp check. + jest.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { ...ACCESS_TOKEN_PAYLOAD, exp: 1000 }, + } as never); + + const validation = await workos.agents.validateCredential({ + type: 'access_token', + credential: 'eyJ.expired.token', + }); + + expect(validation).toEqual({ + valid: false, + registrationId: null, + expiresAt: null, + claims: null, + }); + }); + it('reports an invalid token when verification fails', async () => { jest.mocked(jose.jwtVerify).mockImplementation(() => { const error = new Error('expired') as Error & { code: string }; diff --git a/src/agents/agents.ts b/src/agents/agents.ts index ac763678a..8f2536bc8 100644 --- a/src/agents/agents.ts +++ b/src/agents/agents.ts @@ -16,6 +16,26 @@ import { serializeValidateAgentCredentialOptions, } from './serializers'; +/** + * A decoded JWT payload is only an agent credential if it carries every claim + * the SDK guarantees. A token signed by the same JWKS for another purpose + * (e.g. a user session) lacks these and is rejected rather than reported valid + * with empty identifiers. + */ +function hasRequiredAgentClaims( + payload: import('jose').JWTPayload, +): payload is SerializedAgentAccessTokenClaims { + return ( + typeof payload.iss === 'string' && + (typeof payload.aud === 'string' || Array.isArray(payload.aud)) && + typeof payload.sub === 'string' && + typeof payload.jti === 'string' && + typeof payload.organization_id === 'string' && + typeof payload.exp === 'number' && + typeof payload.iat === 'number' + ); +} + export class Agents { private _jwks?: ReturnType; @@ -89,10 +109,7 @@ export class Agents { return { valid: true, registrationId: claims.registrationId, - expiresAt: - claims.expiresAt != null - ? new Date(claims.expiresAt * 1000).toISOString() - : null, + expiresAt: new Date(claims.expiresAt * 1000).toISOString(), claims, }; } @@ -133,17 +150,17 @@ export class Agents { const jwks = await this.getJWKS(); try { - const { payload } = await jwtVerify( - credential, - jwks, - { audience: audience ?? this.workos.clientId }, - ); + const { payload } = await jwtVerify(credential, jwks, { + audience: audience ?? this.workos.clientId, + }); + + if (!hasRequiredAgentClaims(payload)) { + return null; + } - // A well-formed agent token always carries these claims. A token signed - // by the same JWKS for a different purpose (e.g. a user session) is not a - // valid agent credential, so reject it rather than report it valid with - // empty identifiers. - if (!payload.sub || !payload.jti || !payload.organization_id) { + // Defense in depth: jose already rejects an expired token when `exp` is + // present, but enforce it explicitly so a past expiry is never accepted. + if (payload.exp * 1000 <= Date.now()) { return null; } diff --git a/src/agents/interfaces/validate-agent-credential.interface.ts b/src/agents/interfaces/validate-agent-credential.interface.ts index dd60b05e9..3b52384e6 100644 --- a/src/agents/interfaces/validate-agent-credential.interface.ts +++ b/src/agents/interfaces/validate-agent-credential.interface.ts @@ -42,16 +42,20 @@ export interface SerializedValidateAgentCredentialOptions { audience?: string; } -/** The decoded claims of an agent access token. */ +/** + * The decoded claims of an agent access token. The required fields are + * guaranteed present: the SDK rejects a token that is missing any of them + * rather than returning a partial result. + */ export interface AgentAccessTokenClaims { /** The token issuer (`iss`). */ - issuer?: string; + issuer: string; /** The token audience (`aud`). */ - audience?: string | string[]; + audience: string | string[]; /** Unique identifier of the agent registration the token was issued for (`sub`). */ registrationId: string; /** The token's unique identifier (`jti`). */ - jwtId: string; + jti: string; /** Unique identifier of the Organization the registration belongs to. */ organizationId: string; /** The space-separated scopes granted to the token, if any (`scope`). */ @@ -59,22 +63,29 @@ export interface AgentAccessTokenClaims { /** The actor the token acts on behalf of, if any (`act`). */ actor?: { sub: string }; /** The time the token expires, in seconds since the epoch (`exp`). */ - expiresAt?: number; + expiresAt: number; /** The time the token was issued, in seconds since the epoch (`iat`). */ - issuedAt?: number; + issuedAt: number; } -/** The raw JWT payload of an agent access token, as encoded in the token. */ +/** + * A verified agent access token payload. The required claims are the ones the + * SDK guarantees on a valid agent credential; `scope` and `act` are genuinely + * optional on the token. A decoded payload missing any required claim is + * rejected as invalid before it reaches this shape. + */ export interface SerializedAgentAccessTokenClaims { - iss?: string; - aud?: string | string[]; - sub?: string; - jti?: string; - organization_id?: string; + iss: string; + aud: string | string[]; + sub: string; + jti: string; + organization_id: string; + exp: number; + iat: number; scope?: string; act?: { sub: string }; - exp?: number; - iat?: number; + // A JWT payload may carry additional standard or custom claims. + [claim: string]: unknown; } /** A valid agent credential. */ diff --git a/src/agents/serializers/validate-agent-credential.serializer.ts b/src/agents/serializers/validate-agent-credential.serializer.ts index 6e0642b77..26aa2362a 100644 --- a/src/agents/serializers/validate-agent-credential.serializer.ts +++ b/src/agents/serializers/validate-agent-credential.serializer.ts @@ -48,9 +48,9 @@ export function deserializeAgentAccessTokenClaims( return { issuer: payload.iss, audience: payload.aud, - registrationId: payload.sub ?? '', - jwtId: payload.jti ?? '', - organizationId: payload.organization_id ?? '', + registrationId: payload.sub, + jti: payload.jti, + organizationId: payload.organization_id, scope: payload.scope, actor: payload.act, expiresAt: payload.exp,