# 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 <access_token>

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
