diff --git a/src/agents/agents.spec.ts b/src/agents/agents.spec.ts new file mode 100644 index 000000000..f96c69710 --- /dev/null +++ b/src/agents/agents.spec.ts @@ -0,0 +1,354 @@ +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_reg_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_reg_01EHZNVPK3SFK441A1RGBFSHRT', + jti: '01EHZNVPK3SFK441A1RGBFSHRT', + organizationId: 'org_01EHZNVPK3SFK441A1RGBFSHRT', + scope: 'read write', + actor: undefined, + expiresAt: 4102444800, + issuedAt: 1689646039, +}; + +describe('Agents', () => { + let workos: WorkOS; + + beforeAll(() => { + workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { + apiHostname: 'api.workos.test', + clientId: 'proj_123', + }); + }); + + beforeEach(() => { + fetch.resetMocks(); + jest.mocked(jose.jwtVerify).mockReset(); + }); + + describe('getRegistration', () => { + it('retrieves an agent registration', async () => { + fetchOnce(getAgentRegistrationFixture); + + const registration = await workos.agents.getRegistration( + 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', + ); + + expect(fetchURL()).toContain( + '/agents/registrations/agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', + ); + expect(registration).toEqual({ + id: 'agent_reg_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_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', + }, + createdAt: '2023-07-18T02:07:19.911Z', + 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', () => { + 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', + }); + + expect(fetchURL()).toContain('/agents/credentials/validate'); + expect(fetchBody()).toEqual({ + type: 'api_key', + credential: 'sk_example', + }); + expect(validation).toEqual({ + valid: true, + registrationId: 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', + expiresAt: '2099-01-01T00:00:00.000Z', + claims: null, + }); + }); + + 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 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', () => { + 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(); + // 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_reg_01EHZNVPK3SFK441A1RGBFSHRT', + expiresAt: '2100-01-01T00:00:00.000Z', + claims: EXPECTED_CLAIMS, + }); + }); + + 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 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 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 }; + 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, + }); + }); + + 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_reg_01EHZNVPK3SFK441A1RGBFSHRT', + expiresAt: '2099-01-01T00:00:00.000Z', + claims: EXPECTED_CLAIMS, + }); + }); + + 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) + .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 new file mode 100644 index 000000000..8f2536bc8 --- /dev/null +++ b/src/agents/agents.ts @@ -0,0 +1,199 @@ +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'; + +/** + * 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; + + 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_reg_01EHZNVPK3SFK441A1RGBFSHRT" + * + * @returns {Promise} + * @throws {NotFoundException} 404 + */ + async getRegistration(id: string): Promise { + const { data } = await this.workos.get( + `/agents/registrations/${encodeURIComponent(id)}`, + ); + + return deserializeAgentRegistration(data); + } + + /** + * Validate an agent 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, + options.audience, + ); + + if (!claims) { + return { + valid: false, + registrationId: null, + expiresAt: null, + claims: null, + }; + } + + // 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, + registrationId: claims.registrationId, + expiresAt: new Date(claims.expiresAt * 1000).toISOString(), + 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.valid ? { ...remote, claims } : remote; + } + + private async validateCredentialRemotely( + options: ValidateAgentCredentialOptions, + ): Promise { + const { data } = + await this.workos.post( + '/agents/credentials/validate', + serializeValidateAgentCredentialOptions(options), + ); + + return deserializeAgentCredentialValidation(data); + } + + /** + * 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, 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. + */ + 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. + const jwks = await this.getJWKS(); + + try { + const { payload } = await jwtVerify(credential, jwks, { + audience: audience ?? this.workos.clientId, + }); + + if (!hasRequiredAgentClaims(payload)) { + return null; + } + + // 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; + } + + 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/fixtures/get-agent-registration.json b/src/agents/fixtures/get-agent-registration.json new file mode 100644 index 000000000..4c405ff7c --- /dev/null +++ b/src/agents/fixtures/get-agent-registration.json @@ -0,0 +1,27 @@ +{ + "id": "agent_reg_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_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" + }, + "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..409a3f5bc --- /dev/null +++ b/src/agents/fixtures/validate-agent-credential.json @@ -0,0 +1,5 @@ +{ + "valid": true, + "registration_id": "agent_reg_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..3b52384e6 --- /dev/null +++ b/src/agents/interfaces/validate-agent-credential.interface.ts @@ -0,0 +1,125 @@ +/** The type of agent credential to validate. */ +export type AgentCredentialType = 'api_key' | 'access_token'; + +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; + /** + * 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. 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; +} + +/** + * Options for validating an agent credential. `checkForRevoked` and `audience` + * are only available for `access_token` credentials. + */ +export type ValidateAgentCredentialOptions = + | ValidateAgentApiKeyOptions + | ValidateAgentAccessTokenOptions; + +export interface SerializedValidateAgentCredentialOptions { + type: AgentCredentialType; + credential: string; + audience?: string; +} + +/** + * 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; + /** 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`). */ + jti: 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; +} + +/** + * 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; + exp: number; + iat: number; + scope?: string; + act?: { sub: string }; + // A JWT payload may carry additional standard or custom claims. + [claim: string]: unknown; +} + +/** 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. + */ + expiresAt: string | null; + /** + * 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; + 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..26aa2362a --- /dev/null +++ b/src/agents/serializers/validate-agent-credential.serializer.ts @@ -0,0 +1,59 @@ +import { + AgentAccessTokenClaims, + AgentCredentialValidation, + SerializedAgentAccessTokenClaims, + SerializedAgentCredentialValidation, + SerializedValidateAgentCredentialOptions, + ValidateAgentCredentialOptions, +} from '../interfaces/validate-agent-credential.interface'; + +export function serializeValidateAgentCredentialOptions( + options: ValidateAgentCredentialOptions, +): SerializedValidateAgentCredentialOptions { + 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 }), + }; +} + +export function deserializeAgentCredentialValidation( + validation: SerializedAgentCredentialValidation, +): AgentCredentialValidation { + // 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, + expiresAt: null, + claims: null, + }; + } + + return { + valid: true, + 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, + jti: payload.jti, + organizationId: payload.organization_id, + scope: payload.scope, + actor: payload.act, + expiresAt: payload.exp, + issuedAt: payload.iat, + }; +} 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);