Task — engineering-spec@1

"AUTH SAML 2.0 SSO — SP-initiated flow + per-tenant IdP config + XML signature verification + assertion validation + JIT provisioning + attribute → role mapping + replay defense"

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

§1 — Description (BCP-14 normative)

The AUTH service MUST ship SAML 2.0 SP-initiated SSO with per-tenant IdP config + XML signature verification + assertion validation + JIT provisioning + attribute mapping + replay defense. Each requirement:

  1. MUST define auth_saml_idp_configs table: (id UUID PRIMARY KEY, tenant_id UUID NOT NULL, name TEXT NOT NULL, entity_id_idp TEXT NOT NULL, sso_url TEXT NOT NULL, metadata_url TEXT, x509_cert_pem TEXT NOT NULL, sp_signing_key_kms_blob BYTEA NOT NULL, sp_signing_kms_key_id TEXT NOT NULL, sp_entity_id TEXT NOT NULL, acs_url TEXT NOT NULL, attribute_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, entity_id_idp) WHERE is_active=true.
  1. MUST define auth_saml_login_history table: (id BIGSERIAL, tenant_id UUID, idp_id UUID, subject_id UUID, nameid TEXT NOT NULL, nameid_format 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_saml_authn_request_log table for replay defense: (id UUID PRIMARY KEY, tenant_id UUID, idp_id UUID, issued_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, consumed BOOLEAN NOT NULL DEFAULT false, consumed_at TIMESTAMPTZ). UNIQUE on id; TTL 10 minutes.
  1. MUST define auth_saml_subject_link table: (idp_id UUID, nameid TEXT, tenant_id UUID, subject_id UUID NOT NULL REFERENCES auth.subjects(id), linked_at TIMESTAMPTZ, PRIMARY KEY (idp_id, nameid)). Per-(idp_id, nameid) uniqueness (same shape as TASK-AUTH-104 OIDC).
  1. MUST enforce RLS with USING + WITH CHECK on all 4 tables; root-admin escape clause.
  1. MUST implement SP-initiated flow only (per DEC-520):
  1. MUST fetch IdP metadata at config save time (per DEC-523). Parse EntityDescriptor → extract SingleSignOnService URL + X509Certificate. Cache 24h with kid-style overlap on cert rotation (new cert accepted immediately; old cert accepted for 24h after observed rotation).
  1. MUST require BOTH WantAssertionsSigned=true AND AuthnRequestsSigned=true (per DEC-522). Response-only-signature (Microsoft Azure AD default config) → reject with assertion_signature_required + emit auth.saml_signature_invalid memory row.
  1. MUST enforce closed NameIDFormat (per DEC-524). Accepted formats: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent. Others (transient, unspecified, x509SubjectName, etc.) → 401 nameid_format_unsupported.
  1. MUST implement replay defense via InResponseTo (per DEC-525):
  1. MUST validate assertion conditions (per DEC-526):
  1. MUST verify XML signature with restricted transforms (per DEC-531). Allowed transforms:
  1. MUST require RSA-SHA256 minimum signature algorithm (per DEC-532). SignatureMethod must be one of:
  1. MUST PII-scrub nameid, failure_reason, and the AttributeStatement value contents via TASK-MEMORY-111 before chain commit.
  1. MUST JIT-provision subject on first login (per DEC-528). Same shape as TASK-AUTH-104:
  1. MUST apply per-tenant attribute → role mapping (per DEC-528). Reuses TASK-AUTH-104 YAML shape but with SAML AttributeStatement claim names: ```yaml default_role: tenant-member claim_rules:

Validation: every grant_role MUST parse to TASK-AUTH-101 closed Role enum at config save time.

  1. MUST enforce max 2 active SAML IdP configs per tenant (per DEC-534). 3rd → 409 idp_config_limit_exceeded. ADR required.
  1. MUST ship POST /v1/auth/saml/idp-configs handler for tenant-admin SAML IdP CRUD. Caller MUST have role tenant-admin. Validates:
  1. MUST emit 7 memory audit row kinds (per DEC-529):
  1. MUST complete ACS callback handler in ≤ 500 ms p95 (XML signature verify is the dominant cost). saml_perf_test.
  1. MUST emit OTel span auth.saml.{initiate,acs,jit_provision,idp_config_change,metadata_refresh} with outcome attribute (success | jit_provisioned | unsolicited_response | replay_or_expired | signature_invalid | assertion_signature_required | weak_signature_algorithm | unsupported_transform | audience_mismatch | recipient_mismatch | not_before_violated | not_on_or_after_violated | nameid_format_unsupported | unknown_role | metadata_fetch_failed | sub_already_linked).
  1. MUST emit OTel metrics:
  1. MUST ship the samael crate (or equivalent Rust SAML library audited for security) for XML parsing + signature verification + canonicalization. Hand-rolled XML signature is forbidden — too easy to misimplement (see XSW + XML signature attack literature).
  1. MUST support per-tenant SP entity_id format https://auth.<tenant_slug>.cyberos.world/saml/sp and ACS URL format https://auth.<tenant_slug>.cyberos.world/v1/auth/saml/acs. Validated at IdP config save time.
  1. MUST sign every outbound AuthnRequest with the per-tenant SP signing key (per DEC-522 AuthnRequestsSigned=true). KMS-decrypt the private key for signing; never expose plaintext.
  1. MUST include RequestedAuthnContext with AuthnContextClassRef = urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport at minimum; tenants requiring MFA enforcement at IdP MAY configure MultiFactor via per-tenant override.
  1. MUST support metadata XML download at GET /v1/auth/saml/idp-configs/{id}/sp-metadata — returns SP-side metadata XML for IdP operators to configure their end (entity_id, ACS URL, X.509 signing cert).

§2 — Why this design (rationale for humans)

Why SP-initiated only at slice 1 (DEC-520)? IdP-initiated unsolicited responses lack the InResponseTo binding to a known SP request — an attacker hosting their own IdP can mint assertions for arbitrary subjects (the "evil IdP" attack). OASIS recommends SP-initiated. Some legitimate use cases (bookmark-from-IdP-portal) need IdP-initiated; deferred to task-AUTH-2xx with per-tenant explicit opt-in + additional defenses.

Why both AuthnRequestsSigned AND WantAssertionsSigned (DEC-522)? Signing AuthnRequests proves SP authenticity to IdP (prevents request forgery). Signing assertions proves IdP authenticity to SP (prevents assertion injection). Response-only signing (Azure AD default) is insufficient — the assertion within an unsigned envelope can be swapped via XSW. Both signatures together = full chain validated.

Why exc-c14n only + restricted transforms (DEC-531)? XML Signature Wrapping (XSW) attacks exploit canonicalization differences to validate a signature against one element while the application reads a different element. Restricting transforms to exc-c14n + enveloped-signature eliminates the attack surface; the alternative (xpath, xslt) lets attackers craft assertions that pass signature verification but contain attacker-controlled content.

Why RSA-SHA256 minimum (DEC-532)? SHA-1 collision attacks (SHAttered + Polonen 2020) make forged signatures feasible at nation-state level. NIST has deprecated SHA-1 for digital signatures. SHA-256 minimum is the current baseline; SHA-384 ECDSA accepted for high-security tenants.

Why InResponseTo validation (DEC-525)? Without it, an attacker captures a legitimate SAMLResponse + replays. With it, each response binds to a specific unconsumed AuthnRequest ID — replay fails. 10-min TTL covers slow IdP responses without leaving consumed requests usable indefinitely.

Why ±60s clock skew (DEC-526)? NTP drift between IdP + SP is typically < 1s but can spike during NTP outages. ±60s tolerates outages without false-rejection; tighter would false-reject legitimate flows.

Why Audience + Recipient + InResponseTo checks (§1 #11, DEC-536)? Defense in depth: even if signature verification fails to detect XSW (subtle bug), the Audience + Recipient + InResponseTo checks add second + third + fourth gates. Any missed claim = reject.

Why closed NameIDFormat (DEC-524)? Transient nameids are session-scoped (different each login) — useless for our subject_link table. Unspecified is operator-discretion — could be anything. EmailAddress + persistent are the two formats with stable subject identity over time. Closing the set prevents IdP config drift.

Why per-tenant attribute mapping YAML (DEC-528, §1 #16)? Different IdPs use different attribute names: Okta uses groups; Azure AD uses http://schemas.xmlsoap.org/claims/Group; Google uses scope; ADFS uses claim types. Per-tenant YAML lets each tenant configure their IdP-specific mapping without code changes. Reuses TASK-AUTH-104's YAML shape for consistency.

Why max 2 IdP configs per tenant (DEC-534)? Enterprise tenants typically have one primary IdP (corporate AD) and at most a fallback (e.g. during IdP migration). > 2 suggests config sprawl; ADR-required to raise.

Why samael crate not hand-rolled (§1 #23)? SAML signature verification is famously bug-prone (10+ public CVEs from incorrect parser implementations in 2015-2020 alone). Battle-tested crate is the only safe path. Hand-rolled XML signature is forbidden at slice 1.

Why SLO deferred (DEC-535)? Single Logout (SLO) is operationally complex (cascading logout across multiple SPs; partial-failure handling; user-visible session inconsistency). Slice 1 covers login; tenant session expiry handles logout adequately for slice 1. SLO ships in task-AUTH-2xx when enterprise tenants demand it.

Why KMS-encrypt SP signing key (§1 #25, DEC-527)? SP signing key proves to IdP that AuthnRequests are from us. Compromise = attacker forges AuthnRequests for any subject. KMS encryption requires KMS-decrypt permission to sign — meaningful additional barrier.

Why metadata XML download endpoint (§1 #27)? IdP operators (corporate IT) need our SP metadata (entity_id, ACS URL, signing cert) to configure their side. Standard SAML SP exposes this at a well-known URL. Per-tenant URL makes copy-paste setup easy.

Why min RequestedAuthnContext = PasswordProtectedTransport (§1 #26)? Baseline: IdP must authenticate user with at least password + TLS transport. Lower (e.g. PreviousSession) = relying on browser session — phishable. Tenants requiring MFA at IdP override via config; our slice 1 ships baseline + MFA-via-TASK-AUTH-102 at SP side.

Why 7 memory audit kinds (DEC-529)? Different operator queries: "show me all successful SAML logins this week" → auth.saml_login_succeeded. "Show me signature failures" → auth.saml_signature_invalid. "Show me replay attempts" → auth.saml_assertion_replay_attempted. Selectivity benefits at query time.

Why metadata 24h cache + cert rotation overlap (§1 #7, DEC-523)? IdPs rotate signing certs periodically. 24h cache avoids hot-path metadata refetch. Overlap (old cert + new cert both valid) prevents flap during rotation. Mirrors TASK-AUTH-104 JWKS pattern.

Why per-tenant entity_id format (§1 #24)? Distinct per-tenant entity_id lets IdP-side admins manage per-tenant SP registrations independently. Conflict with another tenant's entity_id is structurally impossible.

Why append-only login_history at SQL grant (§1 #2, DEC-530)? Forensic record of all login attempts including failures. UPDATE/DELETE blocked prevents tampering. Failed logins (with reason) help diagnose attacks + misconfigurations.

Why sev-2 alarm on > 5/h signature failures + > 3/h replay attempts (§1 #22)? Both indicate either (a) IdP misconfiguration + needs operator help, or (b) active attack. Sev-2 = operator investigates within an hour.


§3 — API contract

3.1 — Migration 0021 — idp_configs

-- services/auth/migrations/0021_saml_idp_configs.sql

BEGIN;

CREATE TABLE auth_saml_idp_configs (
    id                          UUID         PRIMARY KEY,
    tenant_id                   UUID         NOT NULL,
    name                        TEXT         NOT NULL CHECK (length(name) BETWEEN 1 AND 100),
    entity_id_idp               TEXT         NOT NULL,
    sso_url                     TEXT         NOT NULL,
    metadata_url                TEXT,
    x509_cert_pem               TEXT         NOT NULL,
    sp_signing_key_kms_blob     BYTEA        NOT NULL,
    sp_signing_kms_key_id       TEXT         NOT NULL,
    sp_entity_id                TEXT         NOT NULL,
    acs_url                     TEXT         NOT NULL,
    attribute_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_saml_idp_name ON auth_saml_idp_configs (tenant_id, name);
CREATE UNIQUE INDEX uniq_active_saml_idp_entity ON auth_saml_idp_configs (tenant_id, entity_id_idp) WHERE is_active = true;

ALTER TABLE auth_saml_idp_configs ENABLE ROW LEVEL SECURITY;
CREATE POLICY saml_idp_configs_tenant_iso ON auth_saml_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 0022 — login_history

-- services/auth/migrations/0022_saml_login_history.sql

BEGIN;

CREATE TABLE auth_saml_login_history (
    id                  BIGSERIAL    PRIMARY KEY,
    tenant_id           UUID         NOT NULL,
    idp_id              UUID         NOT NULL REFERENCES auth_saml_idp_configs(id),
    subject_id          UUID         REFERENCES auth.subjects(id),
    nameid              TEXT         NOT NULL,
    nameid_format       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 saml_login_history_tenant_ts_idx ON auth_saml_login_history (tenant_id, ts DESC);
CREATE INDEX saml_login_history_subject_idx ON auth_saml_login_history (subject_id) WHERE subject_id IS NOT NULL;

ALTER TABLE auth_saml_login_history ENABLE ROW LEVEL SECURITY;
CREATE POLICY saml_login_history_tenant_iso ON auth_saml_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_saml_login_history FROM cyberos_app;

COMMIT;

3.3 — Migration 0023 — authn_request_log (replay defense)

-- services/auth/migrations/0023_saml_authn_request_log.sql

BEGIN;

CREATE TABLE auth_saml_authn_request_log (
    id              UUID         PRIMARY KEY,
    tenant_id       UUID         NOT NULL,
    idp_id          UUID         NOT NULL REFERENCES auth_saml_idp_configs(id),
    issued_at       TIMESTAMPTZ  NOT NULL DEFAULT now(),
    expires_at      TIMESTAMPTZ  NOT NULL,
    consumed        BOOLEAN      NOT NULL DEFAULT false,
    consumed_at     TIMESTAMPTZ
);

CREATE INDEX saml_authn_request_expires_idx ON auth_saml_authn_request_log (expires_at) WHERE consumed = false;

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

REVOKE DELETE ON auth_saml_authn_request_log FROM cyberos_app;
GRANT UPDATE (consumed, consumed_at) ON auth_saml_authn_request_log TO cyberos_app;

COMMIT;

3.4 — Migration 0024 — subject_link

-- services/auth/migrations/0024_saml_subject_link.sql

BEGIN;

CREATE TABLE auth_saml_subject_link (
    idp_id       UUID         NOT NULL REFERENCES auth_saml_idp_configs(id) ON DELETE RESTRICT,
    nameid       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, nameid)
);

CREATE INDEX saml_subject_link_subject_idx ON auth_saml_subject_link (tenant_id, subject_id);

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

COMMIT;

3.5 — AuthnRequest builder

// services/auth/src/saml/authn_request.rs
use chrono::{DateTime, Utc};
use uuid::Uuid;

pub struct AuthnRequestParams {
    pub id: Uuid,
    pub issue_instant: DateTime<Utc>,
    pub destination: String,        // IdP SSO URL
    pub sp_entity_id: String,
    pub acs_url: String,
}

pub fn build_xml(p: &AuthnRequestParams) -> String {
    format!(r#"<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
    ID="_{id}" Version="2.0" IssueInstant="{ts}" Destination="{dest}"
    AssertionConsumerServiceURL="{acs}" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
  <saml:Issuer>{sp}</saml:Issuer>
  <samlp:NameIDPolicy AllowCreate="true" Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"/>
  <samlp:RequestedAuthnContext Comparison="minimum">
    <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
  </samlp:RequestedAuthnContext>
</samlp:AuthnRequest>"#,
        id = p.id.to_simple(), ts = p.issue_instant.to_rfc3339(),
        dest = p.destination, acs = p.acs_url, sp = p.sp_entity_id,
    )
}

3.6 — Response verifier (signature + assertion conditions)

// services/auth/src/saml/response_verifier.rs
use chrono::{DateTime, Duration, Utc};
use samael::{schema::Response, service_provider::ServiceProvider};

const CLOCK_SKEW_SECONDS: i64 = 60;
const ALLOWED_SIG_ALGS: &[&str] = &[
    "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
    "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
    "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
];
const ALLOWED_TRANSFORMS: &[&str] = &[
    "http://www.w3.org/2001/10/xml-exc-c14n#",
    "http://www.w3.org/2000/09/xmldsig#enveloped-signature",
];

#[derive(Debug, thiserror::Error)]
pub enum SamlVerifyError {
    #[error("response_signature_invalid")]
    ResponseSignatureInvalid,
    #[error("assertion_signature_required")]
    AssertionSignatureRequired,
    #[error("assertion_signature_invalid")]
    AssertionSignatureInvalid,
    #[error("weak_signature_algorithm: {0}")]
    WeakSignatureAlgorithm(String),
    #[error("unsupported_transform: {0}")]
    UnsupportedTransform(String),
    #[error("audience_mismatch")]
    AudienceMismatch,
    #[error("recipient_mismatch")]
    RecipientMismatch,
    #[error("not_before_violated")]
    NotBeforeViolated,
    #[error("not_on_or_after_violated")]
    NotOnOrAfterViolated,
    #[error("nameid_format_unsupported: {0}")]
    NameidFormatUnsupported(String),
    #[error("in_response_to_invalid")]
    InResponseToInvalid,
    #[error("unsolicited_response_rejected")]
    UnsolicitedResponseRejected,
}

pub fn verify_response(
    response_xml: &str,
    idp_cert_pem: &str,
    sp_entity_id: &str,
    sp_acs_url: &str,
    expected_in_response_to: Option<&str>,
) -> Result<VerifiedAssertion, SamlVerifyError> {
    // Parse + signature verify via samael
    let sp = ServiceProvider::default()
        .with_idp_certificate_pem(idp_cert_pem)
        .with_entity_id(sp_entity_id);
    let parsed: Response = samael::traits::ToXml::from_xml(response_xml)
        .map_err(|_| SamlVerifyError::ResponseSignatureInvalid)?;

    // (1) Unsolicited check (DEC-520)
    let in_response_to = parsed.in_response_to.as_deref().ok_or(SamlVerifyError::UnsolicitedResponseRejected)?;
    let expected = expected_in_response_to.ok_or(SamlVerifyError::UnsolicitedResponseRejected)?;
    if in_response_to != expected {
        return Err(SamlVerifyError::InResponseToInvalid);
    }

    // (2) Signature algorithm whitelist (DEC-532)
    let sig_alg = parsed.signature.as_ref()
        .map(|s| s.signed_info.signature_method.algorithm.as_str())
        .unwrap_or("");
    if !ALLOWED_SIG_ALGS.iter().any(|a| *a == sig_alg) {
        return Err(SamlVerifyError::WeakSignatureAlgorithm(sig_alg.to_string()));
    }

    // (3) Transforms whitelist (DEC-531 — XSW defense)
    for tr in parsed.signature.as_ref().map(|s| s.signed_info.references.iter()
        .flat_map(|r| r.transforms.iter()
            .flat_map(|t| t.transform.iter()
                .map(|tr| tr.algorithm.as_str())))) {
        for alg in tr {
            if !ALLOWED_TRANSFORMS.iter().any(|a| *a == alg) {
                return Err(SamlVerifyError::UnsupportedTransform(alg.to_string()));
            }
        }
    }

    // (4) Assertion signature required (DEC-522)
    let assertion = parsed.assertions.into_iter().next()
        .ok_or(SamlVerifyError::AssertionSignatureInvalid)?;
    if assertion.signature.is_none() {
        return Err(SamlVerifyError::AssertionSignatureRequired);
    }

    // (5) Audience check (DEC-536)
    let audiences: Vec<String> = assertion.conditions.as_ref()
        .into_iter()
        .flat_map(|c| c.audience_restrictions.iter())
        .flat_map(|ar| ar.audiences.iter().map(|a| a.audience.clone()))
        .collect();
    if !audiences.contains(&sp_entity_id.to_string()) {
        return Err(SamlVerifyError::AudienceMismatch);
    }

    // (6) Recipient check (per DEC-526)
    let recipient_match = assertion.subject.as_ref()
        .and_then(|s| s.subject_confirmations.iter().next())
        .and_then(|sc| sc.subject_confirmation_data.as_ref())
        .map(|d| d.recipient.as_deref() == Some(sp_acs_url))
        .unwrap_or(false);
    if !recipient_match {
        return Err(SamlVerifyError::RecipientMismatch);
    }

    // (7) NotBefore + NotOnOrAfter (60s skew)
    let now = Utc::now();
    if let Some(conds) = assertion.conditions.as_ref() {
        if let Some(nb) = conds.not_before {
            if now + Duration::seconds(CLOCK_SKEW_SECONDS) < nb {
                return Err(SamlVerifyError::NotBeforeViolated);
            }
        }
        if let Some(noa) = conds.not_on_or_after {
            if now - Duration::seconds(CLOCK_SKEW_SECONDS) >= noa {
                return Err(SamlVerifyError::NotOnOrAfterViolated);
            }
        }
    }

    // (8) NameIDFormat closed set
    let (nameid, nameid_format) = assertion.subject.as_ref()
        .and_then(|s| s.name_id.as_ref())
        .map(|n| (n.value.clone(), n.format.clone().unwrap_or_default()))
        .ok_or(SamlVerifyError::NameidFormatUnsupported("missing".into()))?;
    const ALLOWED_FORMATS: &[&str] = &[
        "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
        "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
    ];
    if !ALLOWED_FORMATS.iter().any(|f| *f == nameid_format) {
        return Err(SamlVerifyError::NameidFormatUnsupported(nameid_format));
    }

    Ok(VerifiedAssertion {
        nameid, nameid_format,
        attributes: extract_attributes(&assertion),
    })
}

pub struct VerifiedAssertion {
    pub nameid: String,
    pub nameid_format: String,
    pub attributes: std::collections::HashMap<String, Vec<String>>,
}

fn extract_attributes(assertion: &samael::schema::Assertion) -> std::collections::HashMap<String, Vec<String>> {
    let mut map = std::collections::HashMap::new();
    for stmt in &assertion.attribute_statements {
        for attr in &stmt.attributes {
            let name = attr.name.clone();
            let values: Vec<String> = attr.values.iter().filter_map(|v| v.value.clone()).collect();
            map.insert(name, values);
        }
    }
    map
}

3.7 — ACS handler

// services/auth/src/handlers/saml.rs (excerpt)
use axum::{Form, extract::State, http::StatusCode, response::Redirect};
use crate::saml::{response_verifier, attribute_mapper, jit_provision, audit};
use uuid::Uuid;

#[derive(Deserialize)]
pub struct AcsForm {
    #[serde(rename = "SAMLResponse")]
    pub saml_response_b64: String,
    #[serde(rename = "RelayState")]
    pub relay_state: Option<String>,
}

pub async fn acs_callback(
    State(state): State<AppState>,
    Form(form): Form<AcsForm>,
) -> Result<Redirect, ApiError> {
    // (1) Base64-decode SAMLResponse
    let response_xml = String::from_utf8(
        base64::decode(&form.saml_response_b64).map_err(|_| ApiError::MalformedSamlResponse)?
    ).map_err(|_| ApiError::MalformedSamlResponse)?;

    // (2) Parse InResponseTo to find IdP + AuthnRequest record
    let in_response_to = quick_parse_in_response_to(&response_xml)?;
    let authn_log = state.repo.find_authn_request(in_response_to).await?
        .ok_or(ApiError::SamlUnsolicited)?;
    if authn_log.consumed || authn_log.expires_at < chrono::Utc::now() {
        audit::emit_assertion_replay_attempted(authn_log.tenant_id, in_response_to).await;
        return Err(ApiError::SamlReplayOrExpired);
    }

    // (3) Load IdP config
    let idp = state.repo.load_saml_idp_config(authn_log.idp_id).await?;

    // (4) Verify
    let assertion = match response_verifier::verify_response(
        &response_xml, &idp.x509_cert_pem, &idp.sp_entity_id, &idp.acs_url,
        Some(&in_response_to.to_string()),
    ) {
        Ok(a) => a,
        Err(e) => {
            audit::emit_login_failed(authn_log.tenant_id, authn_log.idp_id, &format!("{e:?}")).await;
            if matches!(e, response_verifier::SamlVerifyError::AssertionSignatureRequired
                       | response_verifier::SamlVerifyError::ResponseSignatureInvalid
                       | response_verifier::SamlVerifyError::AssertionSignatureInvalid
                       | response_verifier::SamlVerifyError::WeakSignatureAlgorithm(_)
                       | response_verifier::SamlVerifyError::UnsupportedTransform(_)) {
                audit::emit_signature_invalid(authn_log.tenant_id, authn_log.idp_id, &format!("{e:?}")).await;
            }
            return Err(ApiError::from(e));
        }
    };

    // (5) JIT provisioning + role mapping
    let role = attribute_mapper::resolve_role(&idp.attribute_mapping_yaml, &assertion.attributes)?;
    let (subject_id, was_jit) = jit_provision::provision_or_link(
        &state, authn_log.tenant_id, authn_log.idp_id, &assertion, role,
    ).await?;

    // (6) Mark AuthnRequest consumed
    state.repo.mark_authn_request_consumed(authn_log.id).await?;

    // (7) Issue AUTH JWT
    let token = state.jwt_issuer.issue_for_subject(subject_id, authn_log.tenant_id).await?;

    // (8) Audit + redirect
    audit::emit_login_succeeded(authn_log.tenant_id, authn_log.idp_id, subject_id, was_jit).await;
    let redirect_url = form.relay_state.unwrap_or_else(|| state.config.default_post_login_url.clone());
    Ok(Redirect::to(&format!("{redirect_url}?access_token={token}")))
}

§4 — Acceptance criteria

  1. SP-initiated flow only — GET /initiate generates AuthnRequest; POST /acs without prior request → 401 unsolicited_response_rejected.
  2. AuthnRequest signed — outbound request XML carries signature.
  3. WantAssertionsSigned required — response with unsigned assertion → 401 assertion_signature_required.
  4. Response signature verified — invalid sig → 401.
  5. SHA-1 algorithm rejected → 401 weak_signature_algorithm.
  6. RSA-SHA256 accepted — happy path.
  7. ECDSA-SHA256 accepted — alternate alg.
  8. Unsupported transform rejected (XSW defense) — xpath transform → 401 unsupported_transform.
  9. InResponseTo replay rejected — second use of same ID → 401 + sev-2 audit.
  10. InResponseTo expired (> 10min) → 401.
  11. Unknown InResponseTo → 401 unsolicited_response.
  12. Audience mismatch → 401.
  13. Recipient mismatch → 401.
  14. NotBefore in future > 60s → 401.
  15. NotOnOrAfter past > 60s → 401.
  16. Clock skew tolerated within 60s — happy path.
  17. NameIDFormat emailAddress accepted — happy path.
  18. NameIDFormat persistent accepted — happy path.
  19. NameIDFormat transient rejected → 401.
  20. JIT provisioning on first login — new subject created; auth.saml_jit_provisioned row.
  21. Subject reused on repeat login — link row hit.
  22. Attribute → role mapping applies — Group/groups attribute matches → granted role.
  23. Unknown role in attribute_mapping_yaml rejected at config save.
  24. Max 2 IdP configs per tenant — 3rd → 409.
  25. SP signing key KMS-encrypted — DB row carries BYTEA blob.
  26. append-only login_history — UPDATE/DELETE blocked.
  27. GET sp-metadata returns SP metadata XML — tenant-admin only.
  28. OTel span emittedauth.saml.acs with outcome.
  29. Counter auth_saml_login_total{outcome=succeeded} increments.
  30. Counter auth_saml_signature_failures_total sev-2 at > 5/h.
  31. Counter auth_saml_replay_attempts_total sev-2 at > 3/h.
  32. ACS handler p95 < 500ms — perf test.

§5 — Verification

// services/auth/tests/saml_xsw_attack_defense_test.rs
#[test]
fn xpath_transform_rejected() {
    let xml_with_xpath_transform = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
        <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
            <SignedInfo><Reference URI="">
                <Transforms><Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"/></Transforms>
                ...
            </Reference></SignedInfo>
        </Signature>
    </samlp:Response>"#;
    let result = cyberos_auth::saml::response_verifier::verify_response(
        xml_with_xpath_transform, mock_cert(), "sp_entity", "https://acs", Some("known_id"),
    );
    assert!(matches!(result, Err(cyberos_auth::saml::response_verifier::SamlVerifyError::UnsupportedTransform(_))));
}
// services/auth/tests/admin_subject_create_test.rs
#[test]
fn sha1_signature_method_rejected() {
    let xml_with_sha1 = mock_response_with_sig_method("http://www.w3.org/2000/09/xmldsig#rsa-sha1");
    let result = cyberos_auth::saml::response_verifier::verify_response(
        &xml_with_sha1, mock_cert(), "sp_entity", "https://acs", Some("known_id"),
    );
    assert!(matches!(result, Err(cyberos_auth::saml::response_verifier::SamlVerifyError::WeakSignatureAlgorithm(_))));
}
// services/auth/tests/saml_assertion_signature_required_test.rs
#[test]
fn response_only_signed_rejected() {
    let xml = mock_response_with_unsigned_assertion();
    let result = cyberos_auth::saml::response_verifier::verify_response(
        &xml, mock_cert(), "sp_entity", "https://acs", Some("id"),
    );
    assert!(matches!(result, Err(cyberos_auth::saml::response_verifier::SamlVerifyError::AssertionSignatureRequired)));
}
// services/auth/tests/admin_revoke_test.rs
#[tokio::test]
async fn reused_in_response_to_rejected(ctx: TestCtx) {
    let req_id = ctx.initiate_flow().await;
    let response = ctx.build_idp_response_for(req_id).await;
    ctx.acs_callback(&response).await.unwrap();   // first use OK
    let err = ctx.acs_callback(&response).await.unwrap_err();
    assert!(format!("{err:?}").contains("SamlReplayOrExpired"));
    let rows = ctx.memory_audit_rows("auth.saml_assertion_replay_attempted").await;
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0]["severity"], "sev-2");
}
// services/auth/tests/saml_nameid_format_closed_test.rs
#[test]
fn transient_nameid_rejected() {
    let xml = mock_response_with_nameid_format("urn:oasis:names:tc:SAML:2.0:nameid-format:transient");
    let result = cyberos_auth::saml::response_verifier::verify_response(
        &xml, mock_cert(), "sp_entity", "https://acs", Some("id"),
    );
    assert!(matches!(result, Err(cyberos_auth::saml::response_verifier::SamlVerifyError::NameidFormatUnsupported(_))));
}

#[test]
fn email_address_format_accepted() {
    let xml = mock_response_with_nameid_format("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
    let result = cyberos_auth::saml::response_verifier::verify_response(
        &xml, mock_cert(), "sp_entity", "https://acs", Some("id"),
    );
    assert!(result.is_ok());
}

§6 — Implementation skeleton

(API contract above is the skeleton; samael crate handles XML signature plumbing; 7 memory row builders follow canonical pattern.)


§7 — Dependencies

Upstream:

Downstream (1 placeholder):

Cross-module:


§8 — Example payloads

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

{
  "name": "Acme ADFS",
  "entity_id_idp": "https://adfs.acme.example/adfs/services/trust",
  "sso_url": "https://adfs.acme.example/adfs/ls/",
  "metadata_url": "https://adfs.acme.example/FederationMetadata/2007-06/FederationMetadata.xml",
  "x509_cert_pem": "-----BEGIN CERTIFICATE-----\nMIID...==\n-----END CERTIFICATE-----",
  "attribute_mapping_yaml": "default_role: tenant-member\nclaim_rules:\n  - attribute: \"http://schemas.xmlsoap.org/claims/Group\"\n    contains: \"Domain Engineers\"\n    grant_role: tenant-admin\n  - attribute: \"http://schemas.xmlsoap.org/claims/Group\"\n    contains: \"CFO Office\"\n    grant_role: cfo\n"
}

8.2 — auth.saml_login_succeeded memory row

{
  "kind": "auth.saml_login_succeeded",
  "tenant_id": "5e8f1d2a-...",
  "idp_id": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
  "subject_id_hash16": "9b1deb4d3b7d4bad",
  "nameid_hash16": "abc123def4567890",
  "nameid_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
  "granted_role": "chief-financial-officer",
  "was_jit": false,
  "ts_ns": 1747920731000000000
}

8.3 — auth.saml_signature_invalid memory row (sev-2)

{
  "kind": "auth.saml_signature_invalid",
  "severity": "sev-2",
  "tenant_id": "5e8f1d2a-...",
  "idp_id": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
  "reason": "WeakSignatureAlgorithm(\"http://www.w3.org/2000/09/xmldsig#rsa-sha1\")",
  "source_ip_hash16": "fed0987654321abc",
  "ts_ns": 1747920731000000000
}

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

{
  "kind": "auth.saml_assertion_replay_attempted",
  "severity": "sev-2",
  "tenant_id": "5e8f1d2a-...",
  "in_response_to": "_abc123-def456-...",
  "source_ip_hash16": "fed0987654321abc",
  "ts_ns": 1747920731000000000
}

§9 — Open questions

Deferred:

All other questions resolved.


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Unsolicited response (no InResponseTo)check401 unsolicited_responseDesigned
InResponseTo replayconsumed/expired check401 + sev-2 auditRe-initiate
InResponseTo expired (>10min)TTL401Re-initiate
Response signature invalidsamael verify401 + sev-2 auditInvestigate
Assertion signature missingverifier401 + sev-2Configure IdP to sign assertions
SHA-1 algorithmalgorithm check401 weak_signature_algorithmUpgrade IdP signing config
xpath transform in signaturetransform check401 unsupported_transformXSW attack defense
Audience mismatchconditions check401Fix IdP config
Recipient mismatchsubject confirmation check401Fix IdP config
NotBefore in futuretimestamp check401Clock sync
NotOnOrAfter pasttimestamp check401Re-initiate
Clock skew > 60swindow check401Sync clocks
Transient nameidformat check401Configure IdP for persistent/emailAddress
Unknown nameid formatformat check401Configure
Unknown role in attribute_mapping_yamlparse_config400 at config saveFix YAML
3rd IdP confighandler check409ADR + cap raise
KMS decrypt fail (SP signing key)aws-sdk error500 + sev-1Rotate key
Metadata fetch failreqwest error500 + sev-3IdP health check
Cross-tenant subject conflictUNIQUE (idp_id, nameid)409Designed
JIT subject create failTASK-AUTH-002 error500 + auditInvestigate
append-only log UPDATE from appSQL grantpermission deniedDesigned
RLS bypassUSING0 rowsDesigned
OTel span attribute missingotel_testCI failsFix
memory audit emit failstx rollback500memory_writer health
Malformed XMLparse error400Designed
Cert rotation: old + new cache overlap24h windowDesignedNone
Signature verifies but XSW substituted elementsamael's hardened verifierNone (defense in depth via Audience + Recipient + transform restrictions)None
> 5/h signature failures sustainedOBS rulesev-2Investigate
> 3/h replay attemptsOBS rulesev-2Investigate
Disabled IdP login attemptis_active check401Re-enable
client_secret leaked in APIhandler omitsNoneDesigned
Concurrent ACS callbacks for same InResponseTorow lock + consumedOne winsDesigned

§11 — Implementation notes


End of TASK-AUTH-103.