Task — engineering-spec@1

"AUTH OIDC SSO — RFC 8414 discovery + RFC 7517 JWKS rotation + per-tenant IdP config + PKCE + JIT subject provisioning + claim → role mapping"

doneTASK-AUTH-104
module auth · class product · priority p0 · created 2026-05-16 · shipped 2026-05-23
depends on TASK-AUTH-004, TASK-AUTH-101 · blocks TASK-TEN-101, TASK-PORTAL-003

§1 — Description (BCP-14 normative)

The AUTH service MUST ship OIDC SSO with RFC 8414 discovery + RFC 7517 JWKS rotation + PKCE Authorisation Code flow + per-tenant IdP config + JIT subject provisioning + claim → role mapping. Each requirement:

  1. MUST define auth_oidc_idp_configs table: (id UUID PRIMARY KEY, tenant_id UUID NOT NULL, name TEXT NOT NULL, issuer_url TEXT NOT NULL, client_id TEXT NOT NULL, client_secret_kms_blob BYTEA NOT NULL, kms_key_id TEXT NOT NULL, redirect_uri TEXT NOT NULL, claim_mapping_yaml TEXT NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ, created_by_subject_id UUID NOT NULL). UNIQUE (tenant_id, name); partial unique (tenant_id, issuer_url) WHERE is_active=true. Max 3 active per tenant (handler check per DEC-412).
  1. MUST define auth_oidc_login_history table: (id BIGSERIAL, tenant_id UUID, idp_id UUID, subject_id UUID (nullable until JIT), sub_claim TEXT NOT NULL, outcome TEXT NOT NULL CHECK (outcome IN ('succeeded','failed','jit_provisioned')), failure_reason TEXT, source_ip_hash16 TEXT, ts TIMESTAMPTZ). REVOKE UPDATE, DELETE FROM cyberos_app.
  1. MUST define auth_oidc_subject_link table: (idp_id UUID, sub_claim TEXT, tenant_id UUID, subject_id UUID NOT NULL REFERENCES auth.subjects(id), linked_at TIMESTAMPTZ, PRIMARY KEY (idp_id, sub_claim)). Per DEC-410, sub uniqueness is per (idp_id, tenant_id); cross-IdP linking is opt-in at slice 3.
  1. MUST enforce RLS with both USING and WITH CHECK on all 3 OIDC tables. Policy: tenant_id = current_setting('auth.tenant_id')::uuid. Discovery + JWKS fetches at gateway are tenant-scoped via the IdP config lookup.
  1. MUST implement RFC 8414 discovery (per DEC-400). Fetch <issuer>/.well-known/openid-configuration once per tenant + cache 1h. Parse authorization_endpoint, token_endpoint, jwks_uri, issuer, response_types_supported, grant_types_supported, code_challenge_methods_supported. Validate: code in response_types, authorization_code in grant_types, S256 in code_challenge_methods. Missing → reject IdP config with oidc_pkce_unsupported.
  1. MUST implement RFC 7517 JWKS fetch + rotation (per DEC-402). Fetch jwks_uri once per kid + cache 24h. On id_token with unknown kid → refetch JWKS; if still unknown → 401 unknown_kid. Old kids accepted for 24h overlap (graceful rotation); new kids accepted immediately.
  1. MUST implement the Authorisation Code flow with PKCE (RFC 7636 + DEC-401):
  1. MUST validate id_token signature + claims (per DEC-411 + §1 #8):
  1. MUST JIT-provision a subject on first login (per DEC-403). If auth_oidc_subject_link WHERE idp_id=$1 AND sub_claim=$2 returns nothing:
  1. MUST apply per-tenant claim → role mapping (per DEC-404). The IdP config's claim_mapping_yaml is a closed-shape YAML: ```yaml default_role: tenant-member claim_rules:

Rules evaluated top-down; first match wins. Unmatched claim values → use default_role. Unknown role in mapping → log + fall back to default_role.

  1. MUST enforce the closed TASK-AUTH-101 role enum on JIT provisioning. claim_mapping_yaml referencing a role not in the catalogue → IdP config save fails with unknown_role: <name>.
  1. MUST support state + nonce replay protection (per DEC-408). Redis stores each state with 10-minute TTL; reuse → 401 oidc_state_reused; expired → 401 oidc_flow_expired. Nonce checked against id_token.
  1. MUST rotate JWKS via 24-hour key-cache overlap (per DEC-402). On id_token with unknown kid, refetch JWKS once; emit auth.oidc_jwks_rotated memory row when new kid first observed.
  1. MUST enforce a per-tenant max of 3 active IdP configs (per DEC-412). Attempt to create a 4th → 409 idp_config_limit_exceeded. ADR required to raise the cap.
  1. MUST emit memory audit rows for 6 kinds (per DEC-406):
  1. MUST PII-scrub sub_claim, email, failure_reason via TASK-MEMORY-111 before chain commit.
  1. MUST complete the OIDC callback handler in ≤ 500 ms p95 (most latency is IdP token endpoint + JWKS fetch; PKCE + claim validation < 50 ms server-side). oidc_perf_test.
  1. MUST emit OTel span auth.oidc.{initiate,callback,jit_provision,idp_config_change} with outcome attribute (success | jit_provisioned | state_reused | flow_expired | signature_invalid | sub_mismatch | unknown_kid | idp_unreachable | claim_mapping_unknown_role).
  1. MUST emit OTel metrics:
  1. MUST ship POST /v1/auth/oidc/idp-configs for tenant-admin IdP config CRUD. Caller MUST have role tenant-admin per TASK-AUTH-101. Validates claim_mapping_yaml + roles + discovery succeeds before persisting.
  1. MUST support PATCH /v1/auth/oidc/idp-configs/{id} (update non-immutable fields) + DELETE (soft-delete via is_active=false); deletion of active IdP with linked subjects requires explicit confirm header X-Confirm-Subject-Deactivate: yes.
  1. MUST reject implicit + hybrid flow (per DEC-401). The initiate handler hard-codes response_type=code; the IdP config validator rejects discovery payloads missing code in response_types_supported.
  1. MUST ensure 60-second clock skew tolerance on exp + nbf claims (per DEC-411 + §1 #8). Wider tolerance → replay risk; tighter → false-reject of legitimate tokens.
  1. MUST support the standard scopes: openid (required), profile, email, plus per-IdP custom scopes specified in idp_config. Unknown IdP-claimed scopes → ignore (not an error).
  1. MUST validate sub_claim uniqueness per (idp_id, tenant_id) (per DEC-410). Same sub appearing twice for different subject_id values → 409 oidc_sub_already_linked. Cross-IdP same sub → distinct subjects (DEC-410).
  1. MUST emit auth.oidc_idp_config_changed memory row with the diff (which fields changed) BUT NOT the new client_secret value (PII-grade secret).
  1. MUST alarm at sev-2 on sustained signature failures > 5/h per tenant (per §1 #18 metric) — suggests IdP key rotation issue OR attack attempt.

§2 — Why this design (rationale for humans)

Why RFC 8414 discovery (DEC-400, §1 #5)? Discovery is the well-known endpoint that publishes the IdP's metadata (token endpoint, JWKS URI, supported algs). Without it, every IdP integration becomes manual config. RFC 8414 standardises the shape; OIDC providers publish at /.well-known/openid-configuration. Caching 1h limits hot-path latency without losing rotation freshness.

Why PKCE mandatory (DEC-401, §1 #6)? Authorization-code interception is the classic OAuth attack — attacker intercepts the redirect_uri callback containing code, exchanges it for token. PKCE binds the code to the original initiator via code_verifier (only the initiator knows the verifier matching the published code_challenge). RFC 7636 mandatory for public clients; we use it for all clients (defense in depth).

Why implicit + hybrid flow forbidden (DEC-401, §1 #21)? Implicit flow returns id_token in the URL fragment — leaks via browser history, referrer headers. Hybrid flow has similar leakage. OAuth 2.1 (draft) deprecates both. Standard auth code with PKCE is the canonical secure pattern.

Why JWKS 24h cache + overlap (DEC-402, §1 #6, §1 #12)? IdPs rotate signing keys periodically. The 24h cache avoids refetching JWKS on every token verification (cost). Overlap means old kids (still in cache) AND new kids (refetched on unknown-kid) both work — graceful rotation. Without overlap, key rotation = forced re-auth for everyone.

Why state + nonce mandatory (DEC-408, §1 #11)? State prevents CSRF (attacker initiates flow + tricks victim to complete callback with attacker's code). Nonce prevents id_token replay (same token can't be reused across login attempts). Both are spec-required; making them ours-required closes the gap if IdP misimplements.

Why 10-minute state TTL (DEC-409)? Login flow should complete within seconds; 10 minutes covers slow IdPs + user delay (e.g. MFA prompt). Past 10 minutes the user has abandoned — force re-initiate.

Why JIT provisioning (DEC-403, §1 #9)? Enterprise SSO works only if user provisioning is automatic. First login → IdP authenticates → AUTH creates the subject from id_token claims. The alternative (require pre-provisioning via admin) defeats the SSO value prop. JIT keeps user lifecycle synced with IdP.

Why claim → role mapping per-tenant YAML (DEC-404, §1 #10)? Different tenants use different IdP claim shapes. Okta uses groups; Azure AD uses roles; Google Workspace uses scope claims. Per-tenant YAML lets each tenant configure their mapping without code changes. Closed-shape YAML (4 supported claim names) prevents drift; explicit default_role covers unmatched cases.

Why max 3 active IdP configs per tenant (DEC-412, §1 #13)? Each active IdP shows up in the login selector UI; > 3 becomes a UX problem. Multi-IdP scenarios (employee + contractor + partner) typically fit ≤ 3. ADR-raise forces operator consideration of UX cost.

Why per-(idp_id, sub_claim) uniqueness, not global sub (DEC-410, §1 #24)? Same sub value from different IdPs are different users (e.g. Google sub=12345 and Okta sub=12345 are unrelated). Scoping uniqueness per IdP prevents accidental cross-linking. Cross-IdP linking (same human via two IdPs) is opt-in at slice 3.

Why append-only login_history (DEC-407)? Login attempts (success + fail) are forensic events. UPDATE/DELETE blocked at SQL grant prevents post-hoc rewriting of "who logged in when".

Why 60s clock skew tolerance (DEC-411, §1 #22)? NTP drift between IdP + our server is typically < 1s but can spike to ~10-30s on misconfigured systems. 60s is generous + below replay window. Tighter would false-reject legitimate tokens.

Why sub_claim PII-scrubbed in memory chain (§1 #15)? sub is typically an opaque IdP-assigned id (e.g. UUID), but some IdPs use email-derived subs. Scrubbing prevents inadvertent PII in audit chain.

Why client_secret KMS-encrypted (§1 #1)? Client secret authenticates the AUTH service to the IdP. Plaintext at rest = compromise on DB dump. KMS encryption requires KMS key permissions for decrypt — meaningful additional barrier.

Why redirect_uri in IdP config not request-supplied (§1 #1, §1 #6)? Redirect_uri is a per-tenant fixed value (e.g. https://auth.cyberos.world/v1/auth/oidc/callback). Allowing request-supplied redirects opens open-redirect attacks. Config-locked redirect prevents.

Why JIT-generated password (subject MUST reset OR rely on SSO) (§1 #9)? Subject needs a password hash to satisfy TASK-AUTH-002's schema. JIT generates a strong random password (zeroised immediately); subject either (a) sets a real password via reset flow if SSO becomes unavailable, OR (b) rely on SSO indefinitely. Common case = (b).

Why sev-2 alarm on signature failures > 5/h (§1 #26)? Normal operation produces zero signature failures. A burst means IdP key rotation not propagated OR active attack (forged tokens). Sev-2 = operator investigation.

Why claim_mapping_yaml rather than JSON (§1 #10)? YAML is human-readable; operators edit by hand at slice 1 (slice 3 may add UI). YAML supports comments — operators can document why a mapping rule exists.

Why slice 1 doesn't ship cross-IdP linking (DEC-410)? Cross-IdP same-human resolution requires identity-matching logic (email match? phone match?) — substantial. Slice 1 keeps it simple: same human via two IdPs = two AUTH subjects. Slice 3 ships the link flow.


§3 — API contract

3.1 — Migration 0010 — idp_configs

-- services/auth/migrations/0010_oidc_idp_configs.sql

BEGIN;

CREATE TABLE auth_oidc_idp_configs (
    id                      UUID         PRIMARY KEY,
    tenant_id               UUID         NOT NULL,
    name                    TEXT         NOT NULL CHECK (length(name) BETWEEN 1 AND 100),
    issuer_url              TEXT         NOT NULL,
    client_id               TEXT         NOT NULL,
    client_secret_kms_blob  BYTEA        NOT NULL,
    kms_key_id              TEXT         NOT NULL,
    redirect_uri            TEXT         NOT NULL,
    claim_mapping_yaml      TEXT         NOT NULL,
    is_active               BOOLEAN      NOT NULL DEFAULT true,
    created_at              TIMESTAMPTZ  NOT NULL DEFAULT now(),
    created_by_subject_id   UUID         NOT NULL REFERENCES auth.subjects(id) ON DELETE RESTRICT
);

CREATE UNIQUE INDEX uniq_idp_name ON auth_oidc_idp_configs (tenant_id, name);
CREATE UNIQUE INDEX uniq_active_idp_issuer ON auth_oidc_idp_configs (tenant_id, issuer_url) WHERE is_active = true;

ALTER TABLE auth_oidc_idp_configs ENABLE ROW LEVEL SECURITY;
CREATE POLICY auth_oidc_idp_configs_tenant_iso ON auth_oidc_idp_configs
    USING (tenant_id = current_setting('auth.tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);

COMMIT;

3.2 — Migration 0011 — login_history

-- services/auth/migrations/0011_oidc_login_history.sql

BEGIN;

CREATE TABLE auth_oidc_login_history (
    id                  BIGSERIAL    PRIMARY KEY,
    tenant_id           UUID         NOT NULL,
    idp_id              UUID         NOT NULL REFERENCES auth_oidc_idp_configs(id),
    subject_id          UUID         REFERENCES auth.subjects(id),
    sub_claim           TEXT         NOT NULL,
    outcome             TEXT         NOT NULL CHECK (outcome IN ('succeeded','failed','jit_provisioned')),
    failure_reason      TEXT,
    source_ip_hash16    TEXT,
    ts                  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE INDEX login_history_tenant_ts_idx ON auth_oidc_login_history (tenant_id, ts DESC);
CREATE INDEX login_history_subject_idx ON auth_oidc_login_history (subject_id) WHERE subject_id IS NOT NULL;

ALTER TABLE auth_oidc_login_history ENABLE ROW LEVEL SECURITY;
CREATE POLICY login_history_tenant_iso ON auth_oidc_login_history
    USING (tenant_id = current_setting('auth.tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);

REVOKE UPDATE, DELETE ON auth_oidc_login_history FROM cyberos_app;

COMMIT;

3.3 — Migration 0012 — subject_link

-- services/auth/migrations/0012_oidc_subject_link.sql

BEGIN;

CREATE TABLE auth_oidc_subject_link (
    idp_id       UUID         NOT NULL REFERENCES auth_oidc_idp_configs(id) ON DELETE RESTRICT,
    sub_claim    TEXT         NOT NULL,
    tenant_id    UUID         NOT NULL,
    subject_id   UUID         NOT NULL REFERENCES auth.subjects(id) ON DELETE RESTRICT,
    linked_at    TIMESTAMPTZ  NOT NULL DEFAULT now(),
    PRIMARY KEY (idp_id, sub_claim)
);

CREATE INDEX subject_link_subject_idx ON auth_oidc_subject_link (tenant_id, subject_id);

ALTER TABLE auth_oidc_subject_link ENABLE ROW LEVEL SECURITY;
CREATE POLICY subject_link_tenant_iso ON auth_oidc_subject_link
    USING (tenant_id = current_setting('auth.tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);

COMMIT;

3.4 — Discovery + JWKS

// services/auth/src/oidc/discovery.rs
use serde::Deserialize;

#[derive(Debug, Clone, Deserialize)]
pub struct OidcDiscovery {
    pub issuer: String,
    pub authorization_endpoint: String,
    pub token_endpoint: String,
    pub jwks_uri: String,
    #[serde(default)]
    pub response_types_supported: Vec<String>,
    #[serde(default)]
    pub grant_types_supported: Vec<String>,
    #[serde(default)]
    pub code_challenge_methods_supported: Vec<String>,
}

pub async fn fetch_discovery(issuer_url: &str) -> anyhow::Result<OidcDiscovery> {
    let url = format!("{issuer_url}/.well-known/openid-configuration");
    let resp = reqwest::get(&url).await?.error_for_status()?;
    let disco: OidcDiscovery = resp.json().await?;
    validate_pkce_support(&disco)?;
    Ok(disco)
}

fn validate_pkce_support(disco: &OidcDiscovery) -> anyhow::Result<()> {
    if !disco.response_types_supported.iter().any(|t| t == "code") {
        anyhow::bail!("oidc_response_type_code_not_supported");
    }
    if !disco.grant_types_supported.iter().any(|g| g == "authorization_code") {
        anyhow::bail!("oidc_auth_code_grant_not_supported");
    }
    if !disco.code_challenge_methods_supported.iter().any(|m| m == "S256") {
        anyhow::bail!("oidc_pkce_s256_unsupported");
    }
    Ok(())
}
// services/auth/src/oidc/jwks.rs
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use arc_swap::ArcSwap;

#[derive(Debug, Clone, Deserialize)]
pub struct Jwk {
    pub kid: String,
    pub kty: String,
    pub alg: String,
    pub n: Option<String>,    // RSA modulus
    pub e: Option<String>,    // RSA exponent
    pub crv: Option<String>,  // EC curve
    pub x: Option<String>,    // EC x
    pub y: Option<String>,    // EC y
    pub k: Option<String>,    // HMAC key (base64url)
}

pub struct JwksCache {
    inner: Arc<ArcSwap<HashMap<String, Jwk>>>,   // kid → Jwk
}

impl JwksCache {
    pub fn new() -> Self {
        Self { inner: Arc::new(ArcSwap::from_pointee(HashMap::new())) }
    }

    pub async fn refetch(&self, jwks_uri: &str) -> anyhow::Result<()> {
        #[derive(Deserialize)] struct JwksDoc { keys: Vec<Jwk> }
        let doc: JwksDoc = reqwest::get(jwks_uri).await?.error_for_status()?.json().await?;
        let map: HashMap<String, Jwk> = doc.keys.into_iter().map(|k| (k.kid.clone(), k)).collect();
        self.inner.store(Arc::new(map));
        Ok(())
    }

    pub fn get(&self, kid: &str) -> Option<Jwk> {
        self.inner.load().get(kid).cloned()
    }
}

3.5 — Authorisation code + PKCE flow

// services/auth/src/oidc/flow.rs
use rand::RngCore;
use sha2::{Digest, Sha256};
use base64::{Engine, engine::general_purpose};

#[derive(Debug, Clone)]
pub struct FlowState {
    pub state: String,
    pub nonce: String,
    pub code_verifier: String,
    pub idp_id: uuid::Uuid,
    pub tenant_id: uuid::Uuid,
    pub expires_at: chrono::DateTime<chrono::Utc>,
}

pub fn generate_flow_state(idp_id: uuid::Uuid, tenant_id: uuid::Uuid) -> FlowState {
    let mut rng = rand::thread_rng();
    let state = random_hex(32, &mut rng);
    let nonce = random_hex(32, &mut rng);
    let mut verifier_bytes = [0u8; 32];
    rng.fill_bytes(&mut verifier_bytes);
    let code_verifier = general_purpose::URL_SAFE_NO_PAD.encode(verifier_bytes);
    FlowState {
        state, nonce, code_verifier, idp_id, tenant_id,
        expires_at: chrono::Utc::now() + chrono::Duration::minutes(10),
    }
}

pub fn code_challenge_from_verifier(verifier: &str) -> String {
    let hash = Sha256::digest(verifier.as_bytes());
    general_purpose::URL_SAFE_NO_PAD.encode(hash)
}

fn random_hex(n_bytes: usize, rng: &mut impl RngCore) -> String {
    let mut buf = vec![0u8; n_bytes];
    rng.fill_bytes(&mut buf);
    hex::encode(buf)
}

pub fn build_auth_url(disco: &super::discovery::OidcDiscovery, idp: &IdpConfig, state: &FlowState) -> String {
    let challenge = code_challenge_from_verifier(&state.code_verifier);
    format!(
        "{auth}?response_type=code&client_id={cid}&redirect_uri={ru}&scope={scope}&state={s}&nonce={n}&code_challenge={c}&code_challenge_method=S256",
        auth = disco.authorization_endpoint,
        cid = urlencoding::encode(&idp.client_id),
        ru = urlencoding::encode(&idp.redirect_uri),
        scope = urlencoding::encode("openid profile email"),
        s = state.state, n = state.nonce, c = challenge,
    )
}

3.6 — id_token verification

// services/auth/src/oidc/id_token.rs
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode_header, decode};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct IdTokenClaims {
    pub iss: String,
    pub sub: String,
    pub aud: serde_json::Value,    // string or array
    pub exp: i64,
    pub nbf: Option<i64>,
    pub iat: i64,
    pub nonce: Option<String>,
    pub email: Option<String>,
    pub preferred_username: Option<String>,
    pub groups: Option<Vec<String>>,
    pub roles: Option<Vec<String>>,
    pub role: Option<String>,
    pub scope: Option<String>,
}

pub fn verify_id_token(
    token: &str,
    jwks: &super::jwks::JwksCache,
    expected_issuer: &str,
    expected_client_id: &str,
    expected_nonce: &str,
) -> Result<IdTokenClaims, OidcError> {
    let header = decode_header(token).map_err(|_| OidcError::TokenMalformed)?;
    let kid = header.kid.as_deref().ok_or(OidcError::TokenKidMissing)?;
    let jwk = jwks.get(kid).ok_or(OidcError::UnknownKid)?;

    let alg = match jwk.alg.as_str() {
        "RS256" => Algorithm::RS256, "ES256" => Algorithm::ES256,
        _ => return Err(OidcError::AlgorithmUnsupported(jwk.alg)),
    };
    let key = make_decoding_key(&jwk, alg)?;
    let mut validation = Validation::new(alg);
    validation.set_issuer(&[expected_issuer]);
    validation.set_audience(&[expected_client_id]);
    validation.leeway = 60;   // 60-second clock skew per DEC-411

    let decoded = decode::<IdTokenClaims>(token, &key, &validation)
        .map_err(|e| OidcError::IdTokenValidation(format!("{e:?}")))?;

    if decoded.claims.nonce.as_deref() != Some(expected_nonce) {
        return Err(OidcError::NonceMismatch);
    }
    Ok(decoded.claims)
}

fn make_decoding_key(jwk: &super::jwks::Jwk, alg: Algorithm) -> Result<DecodingKey, OidcError> {
    match (jwk.kty.as_str(), alg) {
        ("RSA", Algorithm::RS256) => {
            let n = jwk.n.as_deref().ok_or(OidcError::JwkMalformed)?;
            let e = jwk.e.as_deref().ok_or(OidcError::JwkMalformed)?;
            DecodingKey::from_rsa_components(n, e).map_err(|_| OidcError::JwkMalformed)
        }
        ("EC", Algorithm::ES256) => {
            let x = jwk.x.as_deref().ok_or(OidcError::JwkMalformed)?;
            let y = jwk.y.as_deref().ok_or(OidcError::JwkMalformed)?;
            DecodingKey::from_ec_components(x, y).map_err(|_| OidcError::JwkMalformed)
        }
        _ => Err(OidcError::JwkKtyAlgMismatch),
    }
}

3.7 — Claim mapper

// services/auth/src/oidc/claim_mapper.rs
use serde::Deserialize;
use cyberos_auth::rbac::Role;

#[derive(Debug, Deserialize)]
pub struct ClaimMappingConfig {
    pub default_role: String,
    pub claim_rules: Vec<ClaimRule>,
}

#[derive(Debug, Deserialize)]
pub struct ClaimRule {
    pub claim: ClaimName,          // groups | roles | role | scope
    pub contains: String,
    pub grant_role: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ClaimName { Groups, Roles, Role, Scope }

pub fn parse_config(yaml: &str) -> anyhow::Result<ClaimMappingConfig> {
    let cfg: ClaimMappingConfig = serde_yaml::from_str(yaml)?;
    // Validate all referenced roles against TASK-AUTH-101 closed enum
    cfg.default_role.parse::<Role>()
        .map_err(|_| anyhow::anyhow!("unknown_role: {}", cfg.default_role))?;
    for rule in &cfg.claim_rules {
        rule.grant_role.parse::<Role>()
            .map_err(|_| anyhow::anyhow!("unknown_role: {}", rule.grant_role))?;
    }
    Ok(cfg)
}

pub fn resolve_role(cfg: &ClaimMappingConfig, claims: &super::id_token::IdTokenClaims) -> Role {
    for rule in &cfg.claim_rules {
        let claim_values: Vec<String> = match rule.claim {
            ClaimName::Groups => claims.groups.clone().unwrap_or_default(),
            ClaimName::Roles  => claims.roles.clone().unwrap_or_default(),
            ClaimName::Role   => claims.role.clone().map(|r| vec![r]).unwrap_or_default(),
            ClaimName::Scope  => claims.scope.clone().map(|s| s.split(' ').map(String::from).collect()).unwrap_or_default(),
        };
        if claim_values.iter().any(|v| v == &rule.contains) {
            if let Ok(role) = rule.grant_role.parse::<Role>() {
                return role;
            }
        }
    }
    cfg.default_role.parse::<Role>().expect("validated at parse_config")
}

§4 — Acceptance criteria

  1. Discovery fetch + cache — fresh fetch on first IdP config; cached 1h subsequent.
  2. PKCE S256 challenge generated — code_verifier 43-128 chars; challenge = base64url(SHA256(verifier)).
  3. Implicit flow IdP config rejected — discovery missing code in response_types → oidc_response_type_code_not_supported.
  4. Hybrid flow rejected — same path.
  5. Authorisation URL contains required params — response_type, client_id, redirect_uri, scope, state, nonce, code_challenge, code_challenge_method=S256.
  6. State + nonce stored 10-min TTL — Redis lookup recovers code_verifier within window.
  7. Expired state rejected — past TTL → 401 oidc_flow_expired.
  8. Reused state rejected — second callback with same state → 401 oidc_state_reused.
  9. id_token signature verified — happy path: 200; invalid sig: 401 + auth.oidc_signature_invalid memory row.
  10. Unknown kid triggers JWKS refetch — JWKS cache updates; auth.oidc_jwks_rotated row emitted.
  11. iss mismatch rejected — 401 oidc_iss_mismatch.
  12. aud mismatch rejected — 401 oidc_aud_mismatch.
  13. nonce mismatch rejected — 401 oidc_nonce_mismatch.
  14. exp expired (past + 60s skew) → 401 oidc_expired.
  15. JIT provisioning on first login — new sub → subject created + link row; auth.oidc_jit_provisioned memory row.
  16. JIT skipped on repeat login — existing link → subject reused.
  17. Claim mapping grants role — groups: ["Engineering"] → tenant-admin per config.
  18. Default role used on no match — claim mapping has no rule matching → default_role applied.
  19. Unknown role in claim_mapping_yaml rejected at config saveunknown_role: <name> 400.
  20. Max 3 IdP configs per tenant — 4th create → 409 idp_config_limit_exceeded.
  21. append-only login_history — UPDATE/DELETE from cyberos_app rejected.
  22. client_secret KMS-encrypted at rest — DB row contains BYTEA blob, not plaintext.
  23. redirect_uri locked in config — request-supplied redirect_uri ignored.
  24. OIDC callback p95 < 500 msoidc_perf_test.
  25. 5/h signature failure alarm — OBS fires sev-2.
  26. OTel span auth.oidc.callback emitted — outcome attr.
  27. Counter auth_oidc_login_total{outcome=succeeded} increments.

§5 — Verification

// services/auth/tests/geoip_test.rs
#[tokio::test]
async fn pkce_full_flow(ctx: TestCtx) {
    let idp = ctx.seed_idp_config_with_mock_provider().await;
    // 1. Initiate
    let init_resp = ctx.get(&format!("/v1/auth/oidc/initiate?idp_id={}&tenant_slug=acme", idp.id)).await;
    let auth_url = init_resp.headers()["Location"].to_str().unwrap();
    assert!(auth_url.contains("response_type=code"));
    assert!(auth_url.contains("code_challenge="));
    assert!(auth_url.contains("code_challenge_method=S256"));
    // 2. Simulate IdP redirect with code
    let state_param = extract_query_param(auth_url, "state");
    ctx.mock_idp_provider().expect_token_exchange().return_id_token(/* signed by mock JWKS */);
    let cb_resp = ctx.get(&format!("/v1/auth/oidc/callback?code=mock_code&state={state_param}")).await;
    assert_eq!(cb_resp.status(), 302);
    // 3. Subject created (JIT)
    let subjects = ctx.list_subjects().await;
    assert_eq!(subjects.len(), 1);
    let rows = ctx.memory_audit_rows("auth.oidc_jit_provisioned").await;
    assert_eq!(rows.len(), 1);
}
// services/auth/tests/rbac_catalogue_test.rs
#[tokio::test]
async fn reused_state_rejected(ctx: TestCtx) {
    let idp = ctx.seed_idp_config().await;
    let state = ctx.initiate_and_get_state(idp.id).await;
    let _first = ctx.complete_callback(&state, "code1").await.unwrap();
    let second = ctx.complete_callback(&state, "code2").await.unwrap_err();
    assert!(format!("{second:?}").contains("oidc_state_reused"));
}

#[tokio::test]
async fn expired_state_rejected(ctx: TestCtx) {
    let idp = ctx.seed_idp_config().await;
    let state = ctx.initiate_and_get_state(idp.id).await;
    ctx.advance_clock_minutes(11).await;
    let err = ctx.complete_callback(&state, "code1").await.unwrap_err();
    assert!(format!("{err:?}").contains("oidc_flow_expired"));
}
// services/auth/tests/oidc_claim_mapping_test.rs
#[test]
fn unknown_role_in_yaml_rejected() {
    let yaml = "default_role: tenant-member\nclaim_rules:\n  - claim: groups\n    contains: Eng\n    grant_role: super-admin\n";
    let err = cyberos_auth::oidc::claim_mapper::parse_config(yaml).unwrap_err();
    assert!(format!("{err}").contains("unknown_role: super-admin"));
}

#[test]
fn groups_claim_maps_to_role() {
    let yaml = "default_role: tenant-member\nclaim_rules:\n  - claim: groups\n    contains: Engineering\n    grant_role: tenant-admin\n";
    let cfg = cyberos_auth::oidc::claim_mapper::parse_config(yaml).unwrap();
    let claims = IdTokenClaims { groups: Some(vec!["Engineering".into()]), /* ... */ };
    assert_eq!(cyberos_auth::oidc::claim_mapper::resolve_role(&cfg, &claims), Role::TenantAdmin);
}
// services/auth/tests/rbac_adr_gate_test.rs
#[tokio::test]
async fn unknown_kid_triggers_jwks_refetch(ctx: TestCtx) {
    let idp = ctx.seed_idp_config_with_mock_jwks().await;
    ctx.warmup_jwks(idp.id).await;
    let cached_kids_before = ctx.jwks_cache_kids(idp.id).await;
    // IdP rotates: emits new kid
    ctx.mock_provider().rotate_kid("new-kid-001").await;
    let token = ctx.mock_provider().sign_token_with_kid("new-kid-001").await;
    let resp = ctx.callback_with_token(idp.id, &token).await;
    assert_eq!(resp.status(), 302);
    let cached_kids_after = ctx.jwks_cache_kids(idp.id).await;
    assert!(cached_kids_after.contains(&"new-kid-001".to_string()));
    let rotation_rows = ctx.memory_audit_rows("auth.oidc_jwks_rotated").await;
    assert_eq!(rotation_rows.len(), 1);
}

§6 — Implementation skeleton

(API contract above is the skeleton; 6 memory row builders follow the canonical pattern.)


§7 — Dependencies

Upstream:

Downstream (2 placeholders):

Cross-module:


§8 — Example payloads

8.1 — POST /v1/auth/oidc/idp-configs

{
  "name": "Acme Okta",
  "issuer_url": "https://acme.okta.com",
  "client_id": "0oa3xyz...",
  "client_secret": "REDACTED_SECRET",
  "redirect_uri": "https://auth.cyberos.world/v1/auth/oidc/callback",
  "claim_mapping_yaml": "default_role: tenant-member\nclaim_rules:\n  - claim: groups\n    contains: Engineering\n    grant_role: tenant-admin\n  - claim: groups\n    contains: Finance\n    grant_role: cfo\n"
}

8.2 — auth.oidc_login_succeeded memory row

{
  "kind": "auth.oidc_login_succeeded",
  "tenant_id": "5e8f1d2a-...",
  "idp_id": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
  "subject_id_hash16": "9b1deb4d3b7d4bad",
  "sub_claim_hash16": "abc123def4567890",
  "granted_role": "tenant-admin",
  "ts_ns": 1747920731000000000
}

8.3 — auth.oidc_jit_provisioned memory row

{
  "kind": "auth.oidc_jit_provisioned",
  "tenant_id": "5e8f1d2a-...",
  "idp_id": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
  "new_subject_id_hash16": "9b1deb4d3b7d4bad",
  "sub_claim_hash16": "abc123def4567890",
  "email_hash16": "def4567890123456",
  "granted_role": "tenant-member",
  "ts_ns": 1747920731000000000
}

8.4 — auth.oidc_signature_invalid memory row (sev-2)

{
  "kind": "auth.oidc_signature_invalid",
  "severity": "sev-2",
  "tenant_id": "5e8f1d2a-...",
  "idp_id": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
  "source_ip_hash16": "fed0987654321abc",
  "reason": "unknown_kid",
  "ts_ns": 1747920731000000000
}

§9 — Open questions

Deferred:

All other questions resolved.


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
IdP discovery unreachablereqwest timeout500 + sev-3IdP health investigation
Discovery payload lacks PKCEvalidatorIdP config save failsUse PKCE-supporting IdP
Implicit flow IdP configvalidator400Use auth code flow
JWKS unreachablereqwest fail401 + sev-3IdP key endpoint check
Unknown kid (rotation)refetch + retrySuccess or 401 if still unknownDesigned
id_token signature invalidjsonwebtoken401 + sev-2 auditInvestigate
iss mismatchvalidation.set_issuer401Designed
aud mismatchvalidation.set_audience401Designed
exp expiredvalidation + leeway401Re-auth
nbf futurevalidation + leeway401Clock sync
nonce mismatchpost-decode check401CSRF defense
State reusedRedis lookup401Designed
State expiredTTL401Restart flow
Unknown role in claim_mapping_yamlparse_config400 at config saveFix YAML
Claim mapping no matchresolve_role fallbackdefault_role appliedDesigned
4th IdP confighandler check409ADR + cap raise
Cross-tenant sub conflictUNIQUE (idp_id, sub_claim)409Designed
KMS decrypt fail (client_secret)aws-sdk error500 + sev-1Rotate secret
JIT subject create failTASK-AUTH-002 error500 + auditInvestigate
Append-only login_history UPDATESQL grantpermission deniedDesigned
Append-only login_history DELETESQL grantpermission deniedDesigned
RLS bypassUSING0 rowsDesigned
OTel span attribute missingotel_testCI failsFix
Redis unreachable (state store)redis error500Redis health
client_secret leaked in API responsehandler omitsNever exposedDesigned
Concurrent state insertRedis SETNXOne winsDesigned
Disabled IdP login attemptis_active check401Re-enable or use other
Claim mapping YAML malformedserde_yaml error400Fix YAML
> 5/h signature failuresOBS rulesev-2Investigate IdP/attack

§11 — Implementation notes


End of TASK-AUTH-104.