Task — engineering-spec@1

"OAuth 2.1 PKCE authorization-code flow with audience-bound tokens for MCP servers"

doneTASK-MCP-004
module mcp · class product · priority p0 · created 2026-05-16 · shipped null
depends on TASK-AUTH-004, TASK-MCP-001 · blocks TASK-MCP-005, TASK-MCP-006, TASK-MCP-007, TASK-MCP-008

§1 — Description (BCP-14 normative)

The MCP Gateway service MUST implement OAuth 2.1 + PKCE per the MCP 2025-11-25 auth profile, issuing audience-bound JWT access tokens that MCP resource servers verify before dispatching tool calls.

  1. MUST implement exactly two grant types: authorization_code and refresh_token (DEC-800 + DEC-807). Implicit, resource-owner-password, and client_credentials are NOT in scope for this slice (client_credentials is deferred to TASK-MCP-007). Unsupported grant_type returns 400 unsupported_grant_type per RFC 6749 §5.2.
  1. MUST require PKCE on every authorization request from a public client (DEC-803). The authorization request MUST include code_challenge (43-128 char base64url) and code_challenge_method (S256 only per DEC-801). Missing PKCE on a public client returns 400 invalid_request with error_description: "pkce_required_for_public_client". Plain code_challenge_method returns 400 invalid_request with error_description: "pkce_method_must_be_s256".
  1. MUST verify PKCE at the token endpoint: SHA-256(code_verifier) base64url-encoded MUST equal the stored code_challenge. Mismatch returns 400 invalid_grant. The code_verifier MUST be 43-128 chars per RFC 7636.
  1. MUST define a closed 2-value client_type Postgres enum (public, confidential) per DEC-808. Public clients have no client_secret and rely on PKCE for proof. Confidential clients MUST authenticate at the token endpoint via Basic Auth (client_id:client_secret) per RFC 6749 §2.3.1 OR via private_key_jwt (RFC 7523). Both MUST also use PKCE if exposed to a browser context.
  1. MUST define a closed 2-value oauth_grant_type Postgres enum (authorization_code, refresh_token). CI cardinality test asserts exactly 2 (DEC-807).
  1. MUST define a closed 6-value oauth_error_code Postgres enum (invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope) per DEC-820 + RFC 6749 §5.2. CI cardinality test asserts exactly 6.
  1. MUST issue access tokens as signed JWTs using the TASK-AUTH-004 JWKS keys (RS256 or ES256). The token claims MUST include:
  1. MUST issue refresh tokens as opaque 256-bit random base64url strings (NOT JWTs). The refresh token is stored hashed (Argon2 or SHA-256) in oauth_refresh_families table with (family_id, token_hash, issued_at, expires_at, parent_token_hash). TTL = 30 days (DEC-805).
  1. MUST enforce refresh-token rotation (DEC-806). On grant_type=refresh_token, the server:
  1. MUST verify redirect_uri via exact-match comparison against the pre-registered URI (DEC-809). The authorization request's redirect_uri MUST match one of oauth_clients.redirect_uris entries character-for-character. No substring, no regex, no trailing-slash normalization. Mismatch returns 400 invalid_request with error_description: "redirect_uri_mismatch".
  1. MUST validate redirect_uri host on registration (DEC-816). The per-tenant policy mcp_oauth_allowlist_redirect_hosts (JSON array of hostnames or wildcards like *.example.com) restricts which hosts may appear in registered redirect URIs. Default policy: allow any HTTPS host (no host restriction). Tenants opt into stricter policies; tenant_admin role required for policy mutation + sev-2 memory audit.
  1. MUST require https scheme on all redirect URIs in production. The only exception is http://localhost[:port]/... and http://127.0.0.1[:port]/... for native CLI clients (OAuth 2.1 §10.3.3). HTTP redirect to a non-loopback host returns 400 invalid_request at registration.
  1. MUST require the state parameter on every authorization request (DEC-810). Missing state returns 400 invalid_request with error_description: "state_required". The state is opaque to the gateway and returned unchanged on the redirect (CSRF defense).
  1. MUST enforce authorization code TTL = 30 seconds (DEC-811). The oauth_codes row carries expires_at = issued_at + INTERVAL '30 seconds'. An expired code at token exchange returns 400 invalid_grant with error_description: "code_expired".
  1. MUST enforce authorization code one-time-use (DEC-812). On token exchange, the handler:
  1. MUST define a closed 3-value oauth_code_state enum (active, consumed, expired). CI cardinality test asserts exactly 3.
  1. MUST define a closed 3-value oauth_refresh_state enum (active, used, compromised). CI cardinality test asserts exactly 3.
  1. MUST implement RFC 7591 Dynamic Client Registration at POST /register. Request body:
  1. MUST scope confidential client registration to the requesting subject's tenant. The client row carries tenant_id and the issued access tokens carry the same tenant_id. Cross-tenant client lookup returns 404.
  1. MUST implement RFC 8414 Discovery at GET /.well-known/oauth-authorization-server. Response includes: ``json { "issuer": "https://mcp.cyberos.world", "authorization_endpoint": "https://mcp.cyberos.world/authorize", "token_endpoint": "https://mcp.cyberos.world/token", "registration_endpoint": "https://mcp.cyberos.world/register", "revocation_endpoint": "https://mcp.cyberos.world/revoke", "introspection_endpoint": "https://mcp.cyberos.world/introspect", "jwks_uri": "https://mcp.cyberos.world/.well-known/jwks.json", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "private_key_jwt"], "scopes_supported": [<from TASK-MCP-001 registry>] } ``
  1. MUST implement RFC 7009 token revocation at POST /revoke. Body: token=<access_or_refresh>&token_type_hint=.... The handler:
  1. MUST implement RFC 7662 introspection at POST /introspect for resource servers only (DEC-818). The endpoint requires the caller to authenticate as a registered confidential client with the mcp_introspect scope. Public clients receive 401. Response shape per RFC 7662 §2.2. Introspection enables resource servers to verify the token's audience, scope, subject, tenant — without re-implementing JWT validation.
  1. MUST verify audience binding at every MCP resource server hot path (DEC-802). The MCP gateway's tools/call handler MUST:
  1. MUST verify the JWT signature, exp, and revocation list on every request. Cache the revocation list in-process with 60s TTL; misses fetch from Postgres. Defense-in-depth: signature + exp + jti revocation check + audience check.
  1. MUST emit 8 closed memory audit kinds:
  1. MUST route all reason-bearing audit text (error_description, client_name in registration audit) through TASK-MEMORY-111 PII scrubbing.
  1. MUST persist authorization codes in the oauth_codes table with (code, client_id, subject_id, redirect_uri, code_challenge, scope, nonce, state, issued_at, expires_at, consumed_at). The table is append-only via SQL grant + UPDATE limited to consumed_at = now() only via a privileged role oauth_code_consumer.
  1. MUST support prompt=none parameter at the authorize endpoint for silent re-authorization when the subject is already logged in (existing AUTH session). On success, the gateway redirects to redirect_uri with a fresh code immediately. On failure (subject not logged in OR consent not previously granted), redirect to redirect_uri with error=login_required per OIDC §3.1.2.6.
  1. MUST support a consent_screen flow for the first time a subject grants scopes to a client. The consent record is persisted to oauth_consents (subject_id, client_id, scopes, granted_at). Subsequent authorizations with the same or subset scopes skip the consent screen. New scope set requires re-consent.
  1. MUST validate scope against the MCP-server's registered scope set (TASK-MCP-001 tools/list registry — DEC-813). Unknown scopes return 400 invalid_scope. Scope strings are case-sensitive and conform to RFC 6749 §3.3 syntax (visible printable, no whitespace beyond the space separator).

§2 — Rationale (informative — preserve all 22 paragraphs)

§2.1 Why OAuth 2.1 and not OAuth 2.0. DEC-800. OAuth 2.1 is the consolidation IETF draft that removes the unsafe parts of 2.0 (implicit grant, password grant) and codifies the safe practices (PKCE everywhere, exact-match redirect_uri, refresh rotation). The MCP 2025-11-25 auth profile explicitly cites 2.1. Implementing 2.0 with all its options would multiply attack surface; 2.1 is the right baseline.

§2.2 Why PKCE on confidential clients too. Clause #4. OAuth 2.1 §4.1.3.2: "MUST use PKCE if the authorization code flow is initiated in a browser context." A confidential client running a browser-based authorize step is functionally a public client during that step; PKCE protects against the authorization code interception attack regardless of how the client authenticates later.

§2.3 Why S256 only. DEC-801. The S256 code_challenge_method uses SHA-256 to bind code_verifier to the request; the plain method just sends the verifier in clear. Plain offers no protection against an attacker who intercepts the authorization request. OAuth 2.1 §4.1.4: "the plain method MUST NOT be used".

§2.4 Why audience-bound tokens. DEC-802 + RFC 8707. Without audience binding, an access token issued for mcp-a.cyberos.world could be presented at mcp-b.cyberos.world — full cross-server replay. The aud claim + the resource server's exact-match check eliminates this; an attacker who steals a token for one MCP server cannot use it elsewhere.

§2.5 Why refresh-token rotation. DEC-806. A long-lived refresh token is the highest-value credential in OAuth — stolen, it gives the attacker indefinite access. Rotation means every refresh produces a new token and invalidates the old one; an attacker who steals an old refresh token is detected the moment the legitimate client refreshes (the old token's REUSE triggers family compromise). This is the standard "reuse detection" pattern from OAuth 2.1 §6.1.

§2.6 Why we mark the entire family on reuse, not just one token. Clause #9 (refresh) + clause #15 (code). If an attacker has stolen one refresh token, they may have a chain of subsequent refresh tokens too. Invalidating just the reused one leaves the rest of the chain valid. Family compromise revokes the entire descendant chain in one operation.

§2.7 Why 30-second authorization code TTL. DEC-811. The code is meant to be exchanged immediately at the token endpoint (typically ≤ 1 second in practice). OAuth 2.1 recommends ≤10 minutes; we tighten to 30 seconds. This narrows the window where a stolen authorization code could be exchanged. Legitimate clients exchange within sub-second; the 30-second buffer accommodates network jitter.

§2.8 Why one-time-use authorization codes. DEC-812 + clause #15. A code that can be exchanged twice would let an attacker who intercepts the redirect (e.g., via URL-history snooping) silently issue themselves a token while the legitimate user's session also succeeds. One-time-use + reuse-detection makes intercepts visible immediately.

§2.9 Why exact-match redirect_uri. DEC-809 + clause #10. Substring matching enables open-redirect attacks (e.g., registered https://app.example.com matches attacker-controlled https://app.example.com.attacker.com). Regex matching has similar pitfalls. Exact match is the only safe comparison — the cost is operator discipline (register every variant explicitly), the benefit is no class of redirect-attack works.

§2.10 Why per-tenant redirect_uri host allowlist. DEC-816 + clause #11. Even with exact-match, a misconfigured DCR endpoint could allow registration of arbitrary HTTPS URLs. Per-tenant allowlists let tenant_admins lock down what hosts are registrable. The default (no restriction) keeps the gateway open for development; tenants opt in to strictness for production.

§2.11 Why JWT access tokens and opaque refresh tokens. Clauses #7 + #8. JWTs let resource servers verify access tokens without an introspection round-trip (fast hot path). Refresh tokens are presented only at the token endpoint; opaqueness lets us rotate and revoke without leaking structure to clients. The hybrid is standard practice.

§2.12 Why TTL = 1h for access, 30d for refresh. DEC-805. Access TTL of 1h means a stolen token's blast radius is bounded to 1h max (or until JWKS rotation, whichever first). Refresh TTL of 30d covers the common "I open my IDE every couple weeks" pattern without forcing weekly re-auth. Tighter access TTLs increase refresh traffic; looser refresh TTLs increase stolen-credential risk. 1h + 30d is the standard balance.

§2.13 Why DCR for public clients with no caller auth. DEC-804 + clause #18. Public clients (CLIs, desktops) have no pre-existing tenant binding when they first appear; requiring caller auth would create a chicken-and-egg. Open DCR for public clients is safe because (a) public clients can only use the authorization_code flow which requires user interaction, (b) they hold no client_secret, (c) PKCE binds the registered client to the specific authorization request.

§2.14 Why confidential clients require tenant_admin. DEC-804 + clause #18. A confidential client gets a client_secret that authenticates to the token endpoint without user interaction (in client_credentials, deferred to TASK-MCP-007). Issuing such credentials needs tenant accountability; tenant_admin is the right role.

§2.15 Why scopes from the MCP server registry. DEC-813 + clause #30. Scopes are not free-form; they map to specific tool capabilities the MCP server exposes. The TASK-MCP-001 tools/list registry IS the scope authority; the OAuth server validates against it. This keeps the scope vocabulary closed and machine-checkable.

§2.16 Why discovery via RFC 8414. DEC-819 + clause #20. Discovery lets clients learn endpoints + capabilities without hardcoding. RFC 8414 is the standard; MCP profile cites it. The endpoint is at /.well-known/oauth-authorization-server (separate from TASK-MCP-005's Protected Resource Metadata at /.well-known/oauth-protected-resource).

§2.17 Why introspection is closed-network only. DEC-818 + clause #22. Introspection lets a caller learn token validity + claims. Exposing it publicly lets attackers probe valid token shapes. Restricting to authenticated resource servers (with mcp_introspect scope) eliminates the probing surface.

§2.18 Why we revoke via short-lived JWKS keys + revocation list. Clauses #21 + #24 + DEC-817. JWTs are inherently bearer credentials — once issued, they're valid until exp. To revoke before exp, we need either (a) check a revocation list on every verify (small cost; gives true revocation), or (b) rotate JWKS keys so all issued JWTs invalidate together (cheap; coarse). We do both: per-token revocation via jti list + key rotation as a periodic refresh. Revocation list is cached 60s for hot-path performance.

§2.19 Why prompt=none for silent re-auth. Clause #28. CLIs and IDEs that re-authorize on token expiry shouldn't bounce the user through a browser prompt every hour. If the subject has a valid AUTH session and previous consent for this client, prompt=none redirects with a fresh code immediately. This matches OIDC §3.1.2.6 semantics.

§2.20 Why consent screen on first authorization. Clause #29. The subject should knowingly grant scopes to a client. The first authorization shows the scope list + the client name; subsequent authorizations within the same scope set skip. This is the GDPR-aligned consent model adapted to OAuth.

§2.21 Why 8 closed audit kinds. Clause #25. Each kind is a distinct operational signal: authorize-started (intent), token-issued (success), refreshed (renewal), revoked (deliberate end), refresh-reuse (compromise detection), code-reuse (compromise detection), audience-mismatch (cross-server probe), client-registered (DCR). Operators query on kind; collapsing would force free-text parsing.

§2.22 Why we don't support DPoP at this slice. DPoP (RFC 9449) sender-constrains access tokens via per-request signatures. It's the next step after audience binding. We defer it because (a) MCP 2025-11-25 doesn't mandate DPoP, (b) audience + JWKS rotation + revocation list cover the bulk of the threat model, (c) DPoP requires every client to manage signing keys. Revisit in P3 if audit findings demand it.


§3 — API & schema

§3.1 — Migration 0010: oauth_clients + closed enums

-- services/mcp-gateway/migrations/0010_oauth_clients.sql

CREATE TYPE client_type AS ENUM ('public', 'confidential');
CREATE TYPE oauth_grant_type AS ENUM ('authorization_code', 'refresh_token');
CREATE TYPE oauth_error_code AS ENUM (
    'invalid_request',
    'invalid_client',
    'invalid_grant',
    'unauthorized_client',
    'unsupported_grant_type',
    'invalid_scope'
);

CREATE TABLE oauth_clients (
    id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id            UUID REFERENCES tenants(id),   -- NULL for public CLI clients
    client_type          client_type NOT NULL,
    client_secret_hash   TEXT,                          -- NULL for public; Argon2 for confidential
    redirect_uris        JSONB NOT NULL,
    client_name          TEXT CHECK (client_name IS NULL OR length(client_name) <= 64),
    scope                TEXT NOT NULL CHECK (length(scope) BETWEEN 1 AND 1024),
    created_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
    revoked_at           TIMESTAMPTZ,
    CONSTRAINT confidential_has_secret CHECK (
        (client_type = 'confidential' AND client_secret_hash IS NOT NULL)
     OR (client_type = 'public' AND client_secret_hash IS NULL)
    ),
    CONSTRAINT redirect_uris_max_5 CHECK (jsonb_array_length(redirect_uris) BETWEEN 1 AND 5),
    CONSTRAINT confidential_has_tenant CHECK (
        client_type = 'public' OR tenant_id IS NOT NULL
    )
);

CREATE INDEX oauth_clients_tenant ON oauth_clients (tenant_id) WHERE revoked_at IS NULL;

REVOKE UPDATE, DELETE ON oauth_clients FROM cyberos_app;
GRANT INSERT, SELECT, UPDATE(revoked_at) ON oauth_clients TO oauth_writer;
GRANT SELECT ON oauth_clients TO oauth_reader;

§3.2 — Migration 0011: oauth_codes

-- services/mcp-gateway/migrations/0011_oauth_codes.sql

CREATE TYPE oauth_code_state AS ENUM ('active', 'consumed', 'expired');

CREATE TABLE oauth_codes (
    code                TEXT PRIMARY KEY CHECK (length(code) = 43),  -- 256-bit base64url
    client_id           UUID NOT NULL REFERENCES oauth_clients(id),
    subject_id          UUID NOT NULL REFERENCES subjects(id),
    tenant_id           UUID NOT NULL REFERENCES tenants(id),
    redirect_uri        TEXT NOT NULL,
    code_challenge      TEXT NOT NULL CHECK (length(code_challenge) BETWEEN 43 AND 128),
    code_challenge_method TEXT NOT NULL CHECK (code_challenge_method = 'S256'),
    scope               TEXT NOT NULL,
    audience            TEXT NOT NULL,  -- target resource server URL
    nonce               TEXT NOT NULL,
    state               TEXT NOT NULL,  -- client-supplied CSRF state
    issued_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at          TIMESTAMPTZ NOT NULL,
    consumed_at         TIMESTAMPTZ,
    memory_chain_hash    CHAR(64) NOT NULL
);

CREATE INDEX oauth_codes_expires ON oauth_codes (expires_at);

REVOKE UPDATE, DELETE ON oauth_codes FROM cyberos_app;
GRANT INSERT, SELECT, UPDATE(consumed_at) ON oauth_codes TO oauth_code_consumer;

§3.3 — Migration 0012: oauth_refresh_families + revocation_list

-- services/mcp-gateway/migrations/0012_oauth_refresh_families.sql

CREATE TYPE oauth_refresh_state AS ENUM ('active', 'used', 'compromised');

CREATE TABLE oauth_refresh_families (
    id                     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    family_id              UUID NOT NULL,
    client_id              UUID NOT NULL REFERENCES oauth_clients(id),
    subject_id             UUID NOT NULL REFERENCES subjects(id),
    tenant_id              UUID NOT NULL REFERENCES tenants(id),
    audience               TEXT NOT NULL,
    scope                  TEXT NOT NULL,
    token_hash             CHAR(64) NOT NULL,  -- SHA-256 hex
    parent_token_hash      CHAR(64),
    issued_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at             TIMESTAMPTZ NOT NULL,
    state                  oauth_refresh_state NOT NULL DEFAULT 'active',
    state_changed_at       TIMESTAMPTZ,
    memory_chain_hash       CHAR(64) NOT NULL
);

CREATE UNIQUE INDEX oauth_refresh_token_hash ON oauth_refresh_families (token_hash);
CREATE INDEX oauth_refresh_family_active ON oauth_refresh_families (family_id) WHERE state = 'active';

REVOKE UPDATE, DELETE ON oauth_refresh_families FROM cyberos_app;
GRANT INSERT, SELECT, UPDATE(state, state_changed_at) ON oauth_refresh_families TO oauth_refresh_writer;

CREATE TABLE oauth_revocation_list (
    jti           UUID PRIMARY KEY,
    revoked_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at    TIMESTAMPTZ NOT NULL,  -- matches the JWT exp; row TTL-eviction
    reason        TEXT
);

CREATE INDEX oauth_revocation_expires ON oauth_revocation_list (expires_at);

REVOKE UPDATE, DELETE ON oauth_revocation_list FROM cyberos_app;
GRANT INSERT, SELECT ON oauth_revocation_list TO oauth_writer;

CREATE TABLE oauth_consents (
    subject_id   UUID NOT NULL REFERENCES subjects(id),
    client_id    UUID NOT NULL REFERENCES oauth_clients(id),
    scopes       TEXT NOT NULL,
    granted_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (subject_id, client_id)
);

§3.4 — PKCE verification

// services/mcp-gateway/src/oauth/pkce.rs

use sha2::{Sha256, Digest};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};

pub fn verify_pkce(code_verifier: &str, stored_code_challenge: &str) -> bool {
    if code_verifier.len() < 43 || code_verifier.len() > 128 {
        return false;
    }
    let computed = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
    constant_time_eq::constant_time_eq(computed.as_bytes(), stored_code_challenge.as_bytes())
}

§3.5 — Token endpoint (handle authorization_code grant)

// services/mcp-gateway/src/oauth/token.rs

pub async fn token_handler(
    pool: &PgPool,
    body: TokenRequest,
    client_authn: Option<ClientAuth>,
) -> Result<TokenResponse, OAuthError> {
    let client: OAuthClient = load_client(pool, &body.client_id).await?;
    if client.is_confidential() {
        verify_client_authn(&client, &client_authn)?;
    }
    match body.grant_type {
        OAuthGrantType::AuthorizationCode => handle_authz_code(pool, &client, body).await,
        OAuthGrantType::RefreshToken => handle_refresh(pool, &client, body).await,
    }
}

async fn handle_authz_code(pool: &PgPool, client: &OAuthClient, body: TokenRequest)
    -> Result<TokenResponse, OAuthError>
{
    let mut tx = pool.begin().await?;

    // §1 #15 SELECT FOR UPDATE + one-time-use
    let code_row: OAuthCodeRow = sqlx::query_as!(
        OAuthCodeRow,
        r#"SELECT code, client_id, subject_id, tenant_id, redirect_uri,
                  code_challenge, scope, audience, nonce, state,
                  issued_at, expires_at, consumed_at
           FROM oauth_codes WHERE code = $1 FOR UPDATE"#,
        body.code
    ).fetch_optional(&mut *tx).await?.ok_or(OAuthError::InvalidGrant("code_not_found"))?;

    if code_row.client_id != client.id {
        return Err(OAuthError::InvalidGrant("code_client_mismatch"));
    }
    if code_row.expires_at < Utc::now() {
        return Err(OAuthError::InvalidGrant("code_expired"));
    }
    if code_row.consumed_at.is_some() {
        // §1 #15 REUSE detected — mark refresh family compromised
        mark_family_compromised(pool, &client.id, &code_row.subject_id, "code_reuse").await?;
        emit_memory_audit(MemoryKind::OauthCodeReuseDetected, ...).await?;
        return Err(OAuthError::InvalidGrant("code_already_used"));
    }
    if code_row.redirect_uri != body.redirect_uri {
        return Err(OAuthError::InvalidGrant("redirect_uri_mismatch"));
    }

    // PKCE
    if !pkce::verify_pkce(&body.code_verifier, &code_row.code_challenge) {
        return Err(OAuthError::InvalidGrant("pkce_verification_failed"));
    }

    sqlx::query!(
        "UPDATE oauth_codes SET consumed_at = now() WHERE code = $1",
        body.code
    ).execute(&mut *tx).await?;

    // Issue access + refresh
    let access = issue_access_jwt(
        &code_row.subject_id, &code_row.tenant_id, &client.id,
        &code_row.audience, &code_row.scope
    ).await?;
    let refresh = issue_refresh(pool, &client.id, &code_row.subject_id, &code_row.tenant_id,
                                &code_row.audience, &code_row.scope, None).await?;

    emit_memory_audit(MemoryKind::OauthTokenIssued, ...).await?;
    tx.commit().await?;

    Ok(TokenResponse {
        access_token: access.token,
        token_type: "Bearer".to_string(),
        expires_in: 3600,
        refresh_token: Some(refresh.token),
        scope: code_row.scope,
    })
}

§3.6 — Refresh rotation with reuse-detection

async fn handle_refresh(pool: &PgPool, client: &OAuthClient, body: TokenRequest)
    -> Result<TokenResponse, OAuthError>
{
    let mut tx = pool.begin().await?;
    let token_hash = sha256_hex(&body.refresh_token);

    let row: OAuthRefreshRow = sqlx::query_as!(
        OAuthRefreshRow,
        r#"SELECT id, family_id, client_id, subject_id, tenant_id,
                  audience, scope, token_hash, state AS "state: OauthRefreshState", expires_at
           FROM oauth_refresh_families WHERE token_hash = $1 FOR UPDATE"#,
        &token_hash
    ).fetch_optional(&mut *tx).await?.ok_or(OAuthError::InvalidGrant("refresh_not_found"))?;

    if row.client_id != client.id {
        return Err(OAuthError::InvalidGrant("refresh_client_mismatch"));
    }
    if row.expires_at < Utc::now() {
        return Err(OAuthError::InvalidGrant("refresh_expired"));
    }

    match row.state {
        OauthRefreshState::Active => {
            // §1 #9 mark this row as used; issue child
            sqlx::query!(
                "UPDATE oauth_refresh_families SET state = 'used', state_changed_at = now() WHERE id = $1",
                row.id
            ).execute(&mut *tx).await?;

            let access = issue_access_jwt(&row.subject_id, &row.tenant_id, &client.id,
                                          &row.audience, &row.scope).await?;
            let new_refresh = issue_refresh(pool, &client.id, &row.subject_id, &row.tenant_id,
                                           &row.audience, &row.scope, Some(&token_hash)).await?;
            emit_memory_audit(MemoryKind::OauthTokenRefreshed, ...).await?;
            tx.commit().await?;

            Ok(TokenResponse {
                access_token: access.token,
                token_type: "Bearer".to_string(),
                expires_in: 3600,
                refresh_token: Some(new_refresh.token),
                scope: row.scope,
            })
        }
        OauthRefreshState::Used | OauthRefreshState::Compromised => {
            // §1 #9 REUSE — mark entire family compromised
            sqlx::query!(
                r#"UPDATE oauth_refresh_families
                      SET state = 'compromised', state_changed_at = now()
                      WHERE family_id = $1 AND state != 'compromised'"#,
                row.family_id
            ).execute(&mut *tx).await?;
            emit_memory_audit(MemoryKind::OauthRefreshReuseDetected, ...).await?;
            tx.commit().await?;
            Err(OAuthError::InvalidGrant("refresh_reuse_detected"))
        }
    }
}

§3.7 — Audience verification at MCP resource server

// services/mcp-gateway/src/handlers/tools_call.rs (modified)

pub async fn tools_call(
    auth_header: HeaderValue,
    body: ToolsCallRequest,
) -> Result<ToolsCallResponse, McpError> {
    let bearer = parse_bearer(auth_header)?;
    let claims = jwt::verify(&bearer, &JWKS).await?;

    let expected_aud = std::env::var("MCP_RESOURCE_SERVER_URL")?;
    if claims.aud != expected_aud {
        emit_memory_audit(MemoryKind::OauthAudienceMismatch, &claims, &expected_aud).await?;
        return Err(McpError::InvalidToken("audience_mismatch"));
    }

    if is_revoked(&claims.jti).await? {
        return Err(McpError::InvalidToken("token_revoked"));
    }

    // Tenant + scope authorized — dispatch
    dispatch_tool(&body, &claims).await
}

§4 — Acceptance criteria

  1. CI cardinality tests assert client_type (2), oauth_grant_type (2), oauth_error_code (6), oauth_code_state (3), oauth_refresh_state (3).
  2. Public client DCR: POST /register with client_type=public succeeds without caller auth.
  3. Confidential client DCR: requires tenant_admin role.
  4. DCR registers redirect_uris (1-5 URIs); 6th rejected with 400.
  5. Authorization request without code_challenge from public client → 400 pkce_required_for_public_client.
  6. Authorization request with code_challenge_method=plain → 400 pkce_method_must_be_s256.
  7. Authorization request without state → 400 state_required.
  8. Authorization request with substring-matching redirect_uri (not exact) → 400 redirect_uri_mismatch.
  9. Authorization request with HTTP non-loopback redirect → 400 at registration (not at authorize — caught earlier).
  10. Authorization code TTL = 30s; exchange at +31s returns invalid_grant code_expired.
  11. Authorization code one-time-use: second exchange returns invalid_grant code_already_used + marks refresh family compromised + sev-2 audit.
  12. PKCE verification: code_verifier mismatch returns invalid_grant pkce_verification_failed.
  13. Token endpoint issues JWT with claims iss + aud + sub + scope + nonce + iat + exp + jti + client_id + tenant_id.
  14. Access token TTL = 3600s (1h); refresh TTL = 30 days.
  15. Refresh rotation: each refresh returns new refresh_token; old marked used.
  16. Refresh reuse: presenting used refresh_token marks family compromised + invalidates all descendants + sev-1 audit mcp.oauth_refresh_reuse_detected.
  17. Audience mismatch at MCP resource server: returns 401 invalid_token audience_mismatch + sev-2 audit.
  18. Revoked JWT (jti in revocation_list) at /tools/call returns 401 token_revoked.
  19. Revocation list cached in-process with 60s TTL.
  20. Revocation endpoint: POST /revoke with valid token returns 200; same for invalid token (no probing surface).
  21. Introspection endpoint requires mcp_introspect scope; public client request returns 401.
  22. Discovery endpoint (GET /.well-known/oauth-authorization-server) returns RFC 8414 JSON with all required fields.
  23. prompt=none with logged-in subject + previous consent redirects with fresh code immediately.
  24. prompt=none without logged-in subject redirects with error=login_required.
  25. First-time scope grant shows consent screen; subsequent same-scope grants skip.
  26. Scope outside TASK-MCP-001 registry returns 400 invalid_scope.
  27. JWT signed with TASK-AUTH-004 JWKS (RS256 or ES256); signature verified on every request.
  28. oauth_codes / oauth_refresh_families REVOKE UPDATE/DELETE from cyberos_app confirmed; only privileged roles can mutate specific columns.
  29. Per-tenant redirect_uri host allowlist enforced at registration; mutation requires tenant_admin + sev-2 audit.
  30. memory audit row emitted per 8 closed kinds; all reason text scrubbed via TASK-MEMORY-111.
  31. Confidential client authentication: client_secret_basic AND private_key_jwt both supported.
  32. Cross-tenant client lookup returns 404 (tenant isolation at confidential client level).

§5 — Verification (CI tests)


§6 — File skeleton

services/mcp-gateway/
├── src/
│   ├── oauth/
│   │   ├── mod.rs          # pub re-exports
│   │   ├── authorize.rs    # GET /authorize handler
│   │   ├── token.rs        # POST /token (§3.5 + §3.6)
│   │   ├── pkce.rs         # §3.4 verify
│   │   ├── refresh.rs      # rotation + family-compromise
│   │   ├── dcr.rs          # POST /register
│   │   ├── revoke.rs       # POST /revoke
│   │   ├── introspect.rs   # POST /introspect
│   │   ├── discovery.rs    # GET /.well-known/oauth-authorization-server
│   │   ├── audience.rs     # audience verification helper
│   │   ├── scope.rs        # scope validation against TASK-MCP-001 registry
│   │   ├── jwt.rs          # access JWT mint/verify (uses TASK-AUTH-004 JWKS)
│   │   ├── consent.rs      # consent screen handler + persisted record
│   │   ├── audit.rs        # 8 memory audit kinds
│   │   └── error.rs        # OAuthError + RFC 6749 §5.2 mapping
│   └── handlers/
│       └── tools_call.rs   # MODIFIED: verify audience + revocation list
├── migrations/
│   ├── 0010_oauth_clients.sql
│   ├── 0011_oauth_codes.sql
│   └── 0012_oauth_refresh_families.sql
└── tests/
    ├── oauth_authorize_test.rs
    ├── oauth_token_test.rs
    ├── oauth_refresh_rotation_test.rs
    ├── oauth_pkce_test.rs
    ├── oauth_audience_test.rs
    ├── oauth_dcr_test.rs
    ├── oauth_revoke_test.rs
    └── oauth_discovery_test.rs

§7 — Dependencies & blast-radius

Depends on: TASK-AUTH-004 (JWKS for JWT signing), TASK-MCP-001 (scope registry from tools/list).

Blocks: TASK-MCP-005 (Protected Resource Metadata), TASK-MCP-006 (resource indicators), TASK-MCP-007 (client_credentials grant), TASK-MCP-008 (tenant-admin token revocation UI).

Blast radius if broken:


§8 — Payload examples

§8.1 — Authorization request

GET /authorize?
  response_type=code&
  client_id=cli_abc&
  redirect_uri=http://localhost:8080/cb&
  scope=mcp.tools.list%20mcp.tools.call&
  state=xyz&
  code_challenge=E9Melh...&
  code_challenge_method=S256&
  audience=https://mcp-a.cyberos.world

302 → http://localhost:8080/cb?code=AcDe...&state=xyz

§8.2 — Token request

POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AcDe...&
redirect_uri=http://localhost:8080/cb&
client_id=cli_abc&
code_verifier=dBjftJeZ...

200 OK
{
  "access_token": "eyJhbGc...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "vF7-...",
  "scope": "mcp.tools.list mcp.tools.call"
}

§8.3 — Refresh request

POST /token
grant_type=refresh_token&refresh_token=vF7-...&client_id=cli_abc

200 OK
{
  "access_token": "eyJhbGc... (new)",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "Ka9Lm... (new)",
  "scope": "mcp.tools.list mcp.tools.call"
}

§8.4 — Refresh reuse rejection

POST /token
grant_type=refresh_token&refresh_token=vF7-... (already used)

400 Bad Request
{ "error": "invalid_grant", "error_description": "refresh_reuse_detected" }

§8.5 — Audience mismatch at /tools/call

POST /tools/call (at mcp-b.cyberos.world)
Authorization: Bearer eyJ... (aud=mcp-a.cyberos.world)

401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="audience_mismatch"

§9 — Open questions


§10 — Failure modes (33 rows)

#FailureDetectionSevHandler
1PKCE missing on public clientrequest validation3400 + sev-3
2PKCE method = plainrequest validation3400 + sev-3
3code_challenge length out of [43, 128]CHECK3400
4code_verifier mismatchPKCE verify3invalid_grant + sev-3
5Authorization code TTL expiredtimestamp3invalid_grant code_expired
6Authorization code reuseconsumed_at != NULL2invalid_grant + family compromised + sev-2
7Refresh-token reusestate = used1family compromised + sev-1 audit
8Refresh-token compromised familystate = compromised1invalid_grant + sev-1
9Redirect_uri substring (non-exact)string compare3400 redirect_uri_mismatch
10HTTP non-loopback redirect at registrationURL parser3400 invalid_redirect
11State parameter missingrequest validation3400 state_required
12Client_id unknownDB lookup3400 invalid_client
13Confidential client missing client_secretauthn check2401 invalid_client
14Audience mismatch at resource serveraud claim compare2401 + sev-2
15JWT signature invalidJWKS verify1401 + sev-1
16JWT exp passedexp claim3401
17JWT jti in revocation listrevocation list check3401 token_revoked
18Revocation list cache stale > 60sTTL refresh3Refresh from DB
19Scope outside MCP-001 registryscope validation3400 invalid_scope
20DCR public with confidential bodyconstraint violation3400
21DCR > 5 redirect_urisCHECK constraint3400
22DCR client_name > 64 charsCHECK3400
23DCR confidential without tenant_admin roleRBAC2403 + sev-2
24Redirect host not in tenant allowlistpolicy check3400
25Tenant allowlist mutation by non-adminRBAC2403 + sev-2
26prompt=none without active sessionsession check3redirect with error=login_required
27Consent scope wider than previously grantedscope diff3Show consent screen
28Discovery endpoint omits required fieldresponse shape test1CI blocks
29Introspection by public clientRBAC2401 + sev-2
30Cross-tenant client lookupRLS / tenant context2404 + sev-2
31JWT issued without aud claimcode review + test1CI blocks
32Audit emission failssubprocess error1Retry via WAL; sev-1 if exhausted
33Code expired but cleanup job failsTTL cron3Periodic cleanup; sev-3

§11 — Implementation notes

§11.1 All OAuth endpoints serve under a single base path; the Discovery doc lists the absolute URLs.

§11.2 Refresh tokens are stored as SHA-256 hashes (32 bytes), not Argon2 (we need fast lookup; collision-resistance of SHA-256 is sufficient since the token is 256-bit random).

§11.3 Access JWT signing uses the TASK-AUTH-004 active signing key from JWKS. Key rotation invalidates all in-flight access tokens (acceptable — they re-issue via refresh).

§11.4 The revocation list is held in-process per gateway as lru::LruCache<Uuid, Instant> with 60s TTL. On cache miss, the gateway queries oauth_revocation_list and inserts.

§11.5 Authorization codes are cleaned up by a daily cron that DELETEs rows where expires_at < now() - INTERVAL '1 day'. The DELETE goes through oauth_code_cleaner privileged role since the table is REVOKE'd from cyberos_app.

§11.6 The Discovery endpoint is publicly accessible (no auth) — RFC 8414 §3.

§11.7 The Introspection endpoint requires a confidential client with mcp_introspect scope (a system scope reserved for resource servers).

§11.8 Per-tenant redirect host allowlist supports wildcard *.example.com matching the leftmost label; * alone is forbidden.

§11.9 Consent records are scoped to (subject, client) — not (subject, tenant). A subject who consents in one tenant context doesn't automatically consent in another.

§11.10 The audience verification at /tools/call reads the expected canonical URL from env (MCP_RESOURCE_SERVER_URL). The env is set at deploy time per resource-server instance.

§11.11 Tests use the TASK-AUTH-004 testcontainers JWKS fixture for sign + verify.

§11.12 The token endpoint supports both client_secret_basic (RFC 6749 §2.3.1) and private_key_jwt (RFC 7523). private_key_jwt requires the confidential client to have registered a JWKS endpoint at DCR time.

§11.13 Refresh-token rotation: the new token is issued in the same family_id; the chain of parent_token_hash forms a linked list per family. Family compromise sweeps all rows with that family_id.

§11.14 The DCR endpoint emits client credentials in the response once. Lost client_secret cannot be recovered; the tenant must DELETE and re-register.

§11.15 The closed oauth_error_code enum maps to RFC 6749 §5.2's exact strings; the JSON response field is "error" (not "error_code").

§11.16 Authorization codes use 256-bit base64url-no-pad (43 chars); refresh tokens use the same shape. Both random via OS RNG.

§11.17 The consent_screen is HTML rendered by the gateway with the client name + scope descriptions; the descriptions come from the TASK-MCP-001 registry.

§11.18 Token endpoint failure responses include Cache-Control: no-store and Pragma: no-cache per RFC 6749 §5.2.

§11.19 The revocation endpoint returns 200 OK regardless of whether the token existed, to prevent probing.

§11.20 The audience parameter on /authorize defaults to the MCP server origin (https://<host>) when not specified. Explicit specification is required when one client targets multiple resource servers within the same tenant.

§11.21 PKCE constant-time equality prevents timing-channel leakage of the verifier hash.

§11.22 The implementation uses the oauth2 Rust crate as a foundation but layers our audience binding + reuse detection + audit emission on top.


End of TASK-MCP-004 spec.