# OpenID Connect (OIDC) — Specification **Workstream**: 3 of 6 **Phase**: 3 — Enterprise **Author**: Virtual Architect **Date**: 2026-03-29 --- ## Overview Add a full OIDC 1.0 layer on top of the existing OAuth 2.0 `client_credentials` implementation using the certified `oidc-provider` npm library. The OIDC layer exposes Discovery, JWKS, extends the token endpoint to return ID tokens with agent claims, and provides an `/agent-info` endpoint (the agent-identity equivalent of OIDC's `/userinfo`). The existing `POST /oauth2/token` endpoint is extended, not replaced. Callers that do not request the `openid` scope continue to receive standard OAuth 2.0 responses unchanged. --- ## API Endpoints ### GET /.well-known/openid-configuration OIDC Discovery document. No authentication required. This is the standard OIDC Discovery endpoint (RFC 8414 / OpenID Connect Discovery 1.0). ```yaml GET /.well-known/openid-configuration No authentication required Responses: 200 OK: Content-Type: application/json schema: type: object description: OIDC Discovery document per OpenID Connect Discovery 1.0 example: issuer: "https://idp.sentryagent.ai" authorization_endpoint: "https://idp.sentryagent.ai/oauth2/authorize" token_endpoint: "https://idp.sentryagent.ai/oauth2/token" jwks_uri: "https://idp.sentryagent.ai/.well-known/jwks.json" userinfo_endpoint: "https://idp.sentryagent.ai/agent-info" introspection_endpoint: "https://idp.sentryagent.ai/oauth2/introspect" revocation_endpoint: "https://idp.sentryagent.ai/oauth2/revoke" response_types_supported: - "token" grant_types_supported: - "client_credentials" subject_types_supported: - "public" id_token_signing_alg_values_supported: - "RS256" - "ES256" scopes_supported: - "openid" - "agents:read" - "agents:write" - "tokens:read" - "audit:read" claims_supported: - "sub" - "iss" - "iat" - "exp" - "agent_id" - "agent_type" - "organization_id" - "capabilities" - "deployment_env" - "owner" token_endpoint_auth_methods_supported: - "client_secret_post" - "client_secret_basic" 500 Internal Server Error: schema: $ref: '#/components/schemas/ErrorResponse' ``` --- ### GET /.well-known/jwks.json JSON Web Key Set. Contains the public keys used to sign ID tokens and access tokens. No authentication required. Clients use this endpoint to verify token signatures. ```yaml GET /.well-known/jwks.json No authentication required Responses: 200 OK: Content-Type: application/json Cache-Control: public, max-age=3600 schema: type: object required: [keys] properties: keys: type: array items: type: object description: JSON Web Key (RFC 7517) properties: kty: type: string example: "RSA" use: type: string example: "sig" kid: type: string description: Key ID — matches `kid` header in issued JWTs alg: type: string example: "RS256" n: type: string description: RSA modulus (base64url) e: type: string description: RSA exponent (base64url) example: keys: - kty: "RSA" use: "sig" kid: "key-2026-03-29-01" alg: "RS256" n: "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt..." e: "AQAB" 500 Internal Server Error: schema: $ref: '#/components/schemas/ErrorResponse' ``` --- ### POST /oauth2/token (extended) The existing token endpoint is extended to return an `id_token` when the `openid` scope is requested. All existing behavior is preserved when `openid` is not in the scope list. ```yaml POST /oauth2/token Content-Type: application/x-www-form-urlencoded Request Body: schema: type: object required: [grant_type, client_id, client_secret] properties: grant_type: type: string enum: [client_credentials] client_id: type: string client_secret: type: string scope: type: string description: Space-separated scopes. Include "openid" to receive an id_token. example: "openid agents:read" Responses: 200 OK (with openid scope): schema: type: object properties: access_token: type: string token_type: type: string example: "Bearer" expires_in: type: integer scope: type: string id_token: type: string description: Signed JWT ID token containing agent identity claims. Only present when openid scope was requested. example: access_token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." token_type: "Bearer" expires_in: 3600 scope: "openid agents:read" id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI2LTAzLTI5LTAxIn0..." 200 OK (without openid scope): schema: type: object properties: access_token: type: string token_type: type: string expires_in: type: integer scope: type: string example: access_token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." token_type: "Bearer" expires_in: 3600 scope: "agents:read" 400 Bad Request: schema: $ref: '#/components/schemas/OAuthErrorResponse' example: error: "invalid_client" error_description: "Invalid client credentials" 401 Unauthorized: schema: $ref: '#/components/schemas/OAuthErrorResponse' ``` #### ID Token Claims When `openid` scope is requested, the ID token (a signed JWT) contains the following claims: ```json { "iss": "https://idp.sentryagent.ai", "sub": "agt_01HXK7Z9P3FKWABCDEF67890", "aud": "agt_01HXK7Z9P3FKWABCDEF67890", "iat": 1743249600, "exp": 1743253200, "agent_id": "agt_01HXK7Z9P3FKWABCDEF67890", "agent_type": "orchestrator", "organization_id": "org_01HXK7Z9P3FKWABCDEF12345", "capabilities": ["task-planning", "tool-use"], "deployment_env": "production", "owner": "acme-ai", "did": "did:web:idp.sentryagent.ai:agents:agt_01HXK7Z9P3FKWABCDEF67890" } ``` --- ### GET /agent-info Returns claims about the authenticated agent identity. This is the agent-first equivalent of the OIDC `/userinfo` endpoint. Authentication required with any valid access token. ```yaml GET /agent-info Authorization: Bearer Responses: 200 OK: Content-Type: application/json schema: type: object description: Agent identity claims (subset of registered agent data) properties: sub: type: string description: Subject — agentId agent_id: type: string agent_type: type: string organization_id: type: string capabilities: type: array items: type: string deployment_env: type: string owner: type: string version: type: string status: type: string did: type: string description: W3C DID for this agent (if DID workstream is active) created_at: type: string format: date-time example: sub: "agt_01HXK7Z9P3FKWABCDEF67890" agent_id: "agt_01HXK7Z9P3FKWABCDEF67890" agent_type: "orchestrator" organization_id: "org_01HXK7Z9P3FKWABCDEF12345" capabilities: ["task-planning", "tool-use"] deployment_env: "production" owner: "acme-ai" version: "1.2.0" status: "active" did: "did:web:idp.sentryagent.ai:agents:agt_01HXK7Z9P3FKWABCDEF67890" created_at: "2026-03-29T12:00:00Z" 401 Unauthorized: schema: $ref: '#/components/schemas/ErrorResponse' example: code: "UNAUTHORIZED" message: "Invalid or expired access token" ``` --- ## Database Schema Changes ### New Table: oidc_keys Stores the RSA/EC key pairs used for ID token signing. Private keys stored in Vault; public key JWK in PostgreSQL for JWKS endpoint. ```sql CREATE TABLE oidc_keys ( key_id VARCHAR(40) PRIMARY KEY, kid VARCHAR(100) NOT NULL UNIQUE, -- Key ID in JWKS algorithm VARCHAR(10) NOT NULL, use_purpose VARCHAR(10) NOT NULL DEFAULT 'sig', public_key_jwk JSONB NOT NULL, vault_key_path VARCHAR(255) NOT NULL, is_current BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), retired_at TIMESTAMPTZ, CONSTRAINT oidc_keys_alg_check CHECK (algorithm IN ('RS256', 'ES256')), CONSTRAINT oidc_keys_use_check CHECK (use_purpose IN ('sig', 'enc')) ); CREATE INDEX idx_oidc_keys_is_current ON oidc_keys(is_current) WHERE is_current = TRUE; ``` --- ## Configuration | Environment Variable | Description | Default | |---------------------|-------------|---------| | `OIDC_ISSUER` | OIDC issuer URL (must match token `iss` claim) | `https://${HOST}` | | `OIDC_ID_TOKEN_TTL_SECONDS` | ID token lifetime | `3600` | | `OIDC_SIGNING_ALG` | ID token signing algorithm | `RS256` | | `OIDC_JWKS_CACHE_TTL_SECONDS` | JWKS response cache TTL | `3600` | | `OIDC_KEY_ROTATION_DAYS` | Days between signing key rotations | `90` | --- ## Dependencies | Package | Version | Purpose | |---------|---------|---------| | `oidc-provider` | `^8.4.6` | Certified OIDC server library (OpenID Foundation conformant) | --- ## Security Considerations - ID token signing keys are stored in Vault; public keys only are served via JWKS - JWKS endpoint is cached in Redis (`OIDC_JWKS_CACHE_TTL_SECONDS`) to prevent key-hammering - Key rotation: when a new signing key is created, the old key remains in JWKS until all tokens signed with it have expired - The `openid` scope is only issued to callers explicitly requesting it — not included by default - `GET /agent-info` returns the same data as the ID token — no additional sensitive data - ID tokens for agent credentials must not contain client secrets or internal system paths - `alg: none` is explicitly rejected — all ID tokens must be signed --- ## Acceptance Criteria - [ ] `/.well-known/openid-configuration` passes OIDC Discovery conformance validation - [ ] `/.well-known/jwks.json` returns valid JWKS with current signing public key - [ ] ID token returned when `openid` scope is in token request; not returned otherwise - [ ] ID token is verifiable against JWKS endpoint using standard JWT libraries - [ ] ID token claims match agent record (agent_type, capabilities, organization_id, did) - [ ] `/agent-info` returns correct claims for authenticated agent - [ ] Key rotation: old JWKS key is kept until all signed tokens expire - [ ] TypeScript strict, zero `any`, >80% test coverage on OIDCService