cheers-axum 0.8.46

axum 0.8 route handlers for cheers — OIDC login/callback (Google, Apple), passkey, magic-link wiring into a SessionAuthority.
Documentation
//! `GET /.well-known/openid-configuration` — OIDC discovery document.
//!
//! Standard OIDC-discovery shape per the
//! [`mcp-auth-and-ownership.md`](../../../.yah/docs/working/mcp-auth-and-ownership.md)
//! §Discovery section. The doc pins these fields:
//!
//! - `issuer` — the product-supplied cheers issuer URL.
//! - `jwks_uri` — `<issuer>/.well-known/jwks.json` (the [`jwks`](crate::jwks)
//!   route).
//! - `token_endpoint` — `<issuer>/token` (the multi-grant token endpoint
//!   landing in a peer ticket).
//! - `scopes_supported` — read from the deployment's
//!   [`ScopeRegistry`](cheers_core::ScopeRegistry) in [`DiscoveryState`], in
//!   declaration order. The registry is the same one the mint path validates
//!   against, so the discovery doc cannot drift from what cheers will sign.
//! - `grant_types_supported` — RFC 8693 token-exchange plus cheers's
//!   `passkey` grant.
//! - `subject_types_supported` — the three principal kinds the doc enumerates
//!   (`user`, `service`, `camp`). Note this re-uses the OIDC field name to
//!   carry cheers's principal-kind vocabulary, not OIDC's pseudonymity
//!   variants (`public` / `pairwise`) — consistent with the spec doc.
//!
//! ## yah kamaji coordination
//!
//! yah's kamaji serves its own
//! `${kamaji}/.well-known/oauth-protected-resource` that points back at
//! cheers's issuer — that's the discovery hop MCP clients follow to reach
//! cheers. The kamaji does not rewrite this document; it just references
//! cheers's `issuer` field.
//!
//! ## Wiring
//!
//! ```no_run
//! # use std::sync::Arc;
//! # use axum::Router;
//! # use cheers_axum::discovery::{router, DiscoveryState};
//! let scopes = Arc::new(cheers_core::yah_scopes::registry_at(["https://kamaji.example"]).unwrap());
//! let state = Arc::new(DiscoveryState::new("https://cheers.example", scopes));
//! let app: Router = Router::new().merge(router(state));
//! ```
//!
//! @yah:ticket(R731-F5, "POST /token — one token endpoint for client assertion and RFC 8693 exchange (D6)")
//! @yah:status(review)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:at(2026-10-06T23:43:00Z)
//! @yah:phase(P2)
//! @yah:parent(R731)
//! @yah:next("Doc D6. The exchange mint side exists (mint_token_exchange, user + camp); the route does not. Discovery already advertises token_endpoint=/token and the token-exchange grant type (discovery.rs:60,69), so until this lands it advertises a dead endpoint.")
//! @yah:next("Takes audience + scope; grant_type selects assertion or exchange. Replaces per-feature mint doors in consumers (noisetable issues, diagnostics, assist).")
//! @yah:next("yubaba (oss/yubaba cheers_client.rs:204 self-signs an ownership:write token) moves onto this endpoint; coordinate in the yah camp.")
//! @yah:depends_on(R731-F4)
//! @yah:tier(Warrior)
//! @yah:next("F5 DECIDED (leader, 2026-10-06). POST /token, form-encoded (application/x-www-form-urlencoded), one route in cheers-axum dispatching on grant_type. (a) `urn:ietf:params:oauth:grant-type:token-exchange` (RFC 8693): subject_token, subject_token_type, audience, scope (space-separated), routed to mint_token_exchange; the response carries issued_token_type. (b) `client_credentials` with client_assertion_type = `urn:cheers:client-assertion-type:paseto-v4-public` plus client_assertion, routed to mint_service. The assertion is PASETO, not a JWT, so do NOT advertise RFC 7523's jwt-bearer URN. Success is RFC 6749 §5.1 JSON {access_token, token_type: \"Bearer\", expires_in, scope}. Errors are RFC 6749 §5.2 JSON {error: invalid_request | invalid_client | invalid_grant | invalid_scope | unsupported_grant_type}, with 400, or 401 for invalid_client. Discovery: token_endpoint stays /token; grant_types_supported lists both; add token_endpoint_auth_methods_supported naming the cheers assertion type. A test pins that discovery advertises only grants the route serves. yubaba (oss/yubaba cheers_client.rs) is NOT edited here: append the exact client flow to yah R426 so the yah-side relay moves it.")
//! @yah:handoff("NEW crates/cheers-axum/src/token_endpoint.rs (pub mod in lib.rs): TokenEndpointState<B,G,O,V,Rd>{mcp: Arc<McpAuthority>, edge: Arc<EdgeVerifier> (verifies the user session), camps: Arc<dyn CampPrincipalStore> (resolves the camp credential)} + router(state) mounting POST /token. Form-encoded; every field is Option so missing params are a 5.2 invalid_request, not an axum rejection. Dispatches on grant_type.")
//! @yah:handoff("RFC 8693 mapping (route doc comment has the table): subject_token = camp bootstrap credential, subject_token_type=urn:cheers:token-type:camp-bootstrap (unknown, expired, revoked or non-Active camp -> invalid_grant); actor_token = user session access token, actor_token_type=urn:ietf:params:oauth:token-type:access_token (EdgeVerifier failure -> invalid_grant); audience -> aud; scope (space-separated, required non-empty) -> requested_scope; act=None because no wire param carries an agent. issued_token_type=urn:ietf:params:oauth:token-type:access_token. This follows mint_token_exchange's own doc: camp is the subject, user is the actor, sub of the result is the user.")
//! @yah:handoff("client_credentials: client_assertion_type must be urn:cheers:client-assertion-type:paseto-v4-public (otherwise invalid_client), client_assertion -> mint_service, audience required, scope optional (empty = everything the relationship grants).")
//! @yah:handoff("map_mint_error (pub): Assertion{Malformed,UnknownKid,RetiredKey,PrincipalMismatch,BadAudience,Expired,LifetimeTooLong} -> invalid_client 401; AssertionReplayed -> invalid_grant; AudNotEntitled/InvalidScope/UnentitledScopes/NoGrantedScopes -> invalid_scope; TtlOutOfRange -> invalid_request; Store/JtiStore -> 503 temporarily_unavailable; everything else -> 500 server_error with a generic description (logged). Unregistered or unparsable scope -> invalid_scope. Success = 6749 5.1 JSON + issued_token_type on exchange; every response carries Cache-Control: no-store.")
//! @yah:handoff("Discovery: GRANT_TYPES_SUPPORTED is now owned by token_endpoint and re-exported by discovery (so they cannot drift) = [token-exchange, client_credentials]. passkey is REMOVED because the route serves no passkey grant. New field token_endpoint_auth_methods_supported=[urn:cheers:client-assertion-type:paseto-v4-public]. Inline discovery tests updated.")
//! @yah:handoff("Tests: tests/token_endpoint_basic.rs (mod added to tests/main.rs) has 6 cases: assertion flow with scope from a seeded relationship, verified at the edge; replay -> invalid_grant; garbage assertion -> 401 invalid_client; unknown grant_type -> unsupported_grant_type, missing grant_type -> invalid_request; full exchange (camp provisioned via CampAuthority with a signed delegation, real session) plus scope outside the camp's grants -> invalid_scope and an unknown credential -> invalid_grant; discovery advertises exactly the 2 grants + auth method and each advertised grant dispatches. pasetors 0.7 added as a cheers-axum dev-dep.")
//! @yah:handoff("yubaba was not edited. The client flow is appended to yah R426 as a gotcha. F8 files were not touched; lib.rs got one line and tests/main.rs got one line.")
//! @yah:verify("cargo test --workspace --no-fail-fast (oss/cheers): 702 passed / 0 failed / 4 ignored, against the 696/0/4 baseline measured before the first edit. My +6 are the token_endpoint_basic tests.")
//! @yah:verify("cargo test -p cheers-axum --test main token_endpoint_basic: 6/6 pass with no new warnings.")
//! @yah:handoff("CORRECTION (leader follow-up): the first RFC 8693 mapping had subject and actor inverted. Corrected mapping: subject_token = the user session (urn:ietf:params:oauth:token-type:access_token, verified by EdgeVerifier) is the party the token is ABOUT; actor_token = the camp bootstrap credential (urn:cheers:token-type:camp-bootstrap, resolved via CampPrincipalStore) is the party ACTING for the user. The route passes actor = Some(Actor::new(camp)) to mint_token_exchange, so the issued token carries sub=user, camp_id=camp and act={sub: camp:<id>} (RFC 8693 §4.1), which F8's ownership attribution reads as act->granted_by and sub->on_behalf_of. No more specific actor is reachable from the wire, so the camp is always the actor. The exchange test now asserts sub, act.sub and camp_id. Two rejection cases were added: swapped roles with honest type labels -> invalid_request, and swapped tokens under the correct labels -> invalid_grant. Workspace: 702/0/4, unchanged from the prior end state (the new assertions sit inside the existing test).")
//! @yah:verify("Leader final re-verify 2026-10-06 (quiet tree, no skew): cheers workspace 702 pass / 0 fail / 4 ignored, including token_endpoint_basic. The RFC 8693 mapping was corrected in-ticket after leader review: subject_token = user session, actor_token = camp bootstrap credential, and the issued token has sub = user, act = {sub: camp}. That matches the act -> granted_by / sub -> on_behalf_of attribution in R731-F8's ownership door.")

use std::sync::Arc;

use axum::Json;
use axum::Router;
use axum::extract::State;
use axum::routing::get;
use serde::{Deserialize, Serialize};

use cheers_core::ScopeRegistry;

/// Path the route is mounted at — `<issuer>/.well-known/openid-configuration`.
pub const OPENID_CONFIGURATION_PATH: &str = "/.well-known/openid-configuration";

/// Sub-path of `jwks_uri` relative to the issuer — kept verbatim with the
/// path the [`jwks`](crate::jwks) module mounts.
pub const JWKS_PATH: &str = "/.well-known/jwks.json";

/// Sub-path of `token_endpoint` relative to the issuer.
pub const TOKEN_ENDPOINT_PATH: &str = "/token";

/// `subject_types_supported` values per the doc. Cheers's three principal
/// kinds, not OIDC's pseudonymity variants — see the module docs.
pub const SUBJECT_TYPES_SUPPORTED: &[&str] = &["user", "service", "camp"];

/// `grant_types_supported`: exactly the grants `POST /token` serves (R731-F5)
/// — RFC 8693 token-exchange and `client_credentials`. Re-exported from the
/// route so discovery cannot advertise a grant the endpoint does not take.
pub use crate::token_endpoint::{GRANT_TYPES_SUPPORTED, TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED};

/// State held by the discovery handler.
///
/// The issuer URL plus the deployment's [`ScopeRegistry`]; every other field
/// is static.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DiscoveryState {
    /// The cheers issuer URL (no trailing slash). Used as `issuer` and as the
    /// prefix for `jwks_uri` and `token_endpoint`.
    pub issuer: String,
    /// The deployment's scope vocabulary; `scopes_supported` lists it.
    pub scopes: Arc<ScopeRegistry>,
}

impl DiscoveryState {
    pub fn new(issuer: impl Into<String>, scopes: Arc<ScopeRegistry>) -> Self {
        Self {
            issuer: issuer.into(),
            scopes,
        }
    }
}

/// The OIDC discovery document body. Fields match the doc's §Discovery
/// example verbatim. `#[non_exhaustive]` so additional fields can land
/// without a breaking API change.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct OpenIdConfiguration {
    pub issuer: String,
    pub jwks_uri: String,
    pub token_endpoint: String,
    pub scopes_supported: Vec<String>,
    pub grant_types_supported: Vec<String>,
    pub token_endpoint_auth_methods_supported: Vec<String>,
    pub subject_types_supported: Vec<String>,
}

/// Mount `GET /.well-known/openid-configuration`.
pub fn router(state: Arc<DiscoveryState>) -> Router {
    Router::new()
        .route(OPENID_CONFIGURATION_PATH, get(openid_configuration))
        .with_state(state)
}

async fn openid_configuration(
    State(state): State<Arc<DiscoveryState>>,
) -> Json<OpenIdConfiguration> {
    Json(build_configuration(&state.issuer, &state.scopes))
}

fn build_configuration(issuer: &str, scopes: &ScopeRegistry) -> OpenIdConfiguration {
    OpenIdConfiguration {
        issuer: issuer.to_string(),
        jwks_uri: format!("{issuer}{JWKS_PATH}"),
        token_endpoint: format!("{issuer}{TOKEN_ENDPOINT_PATH}"),
        scopes_supported: scopes.iter().map(|d| d.scope.as_wire().to_string()).collect(),
        grant_types_supported: GRANT_TYPES_SUPPORTED
            .iter()
            .map(|s| (*s).to_string())
            .collect(),
        token_endpoint_auth_methods_supported: TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED
            .iter()
            .map(|s| (*s).to_string())
            .collect(),
        subject_types_supported: SUBJECT_TYPES_SUPPORTED
            .iter()
            .map(|s| (*s).to_string())
            .collect(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use cheers_core::yah_scopes;

    use axum::body::{Body, to_bytes};
    use axum::http::{Request, StatusCode, header};
    use tower::ServiceExt;

    async fn body_json<T: for<'de> serde::Deserialize<'de>>(body: Body) -> T {
        let bytes = to_bytes(body, 64 * 1024).await.expect("body bytes");
        serde_json::from_slice(&bytes).expect("json decode")
    }

    fn app() -> Router {
        app_with(Arc::new(yah_scopes::registry_at(["https://cheers.example"]).unwrap()))
    }

    fn app_with(scopes: Arc<ScopeRegistry>) -> Router {
        let state = Arc::new(DiscoveryState::new("https://cheers.example", scopes));
        Router::new().merge(router(state))
    }

    async fn fetch_configuration() -> OpenIdConfiguration {
        let req = Request::builder()
            .method("GET")
            .uri(OPENID_CONFIGURATION_PATH)
            .body(Body::empty())
            .unwrap();
        let resp = app().oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
        let ct = resp
            .headers()
            .get(header::CONTENT_TYPE)
            .expect("content-type")
            .clone();
        assert!(
            ct.to_str().unwrap().starts_with("application/json"),
            "content-type must be JSON, got {ct:?}",
        );
        body_json(resp.into_body()).await
    }

    #[tokio::test]
    async fn discovery_doc_matches_known_good_fixture() {
        let cfg = fetch_configuration().await;
        let expected_scopes: Vec<String> =
            yah_scopes::DEFS.iter().map(|d| d.scope.as_wire().to_string()).collect();
        let expected = OpenIdConfiguration {
            issuer: "https://cheers.example".into(),
            jwks_uri: "https://cheers.example/.well-known/jwks.json".into(),
            token_endpoint: "https://cheers.example/token".into(),
            scopes_supported: expected_scopes,
            grant_types_supported: vec![
                "urn:ietf:params:oauth:grant-type:token-exchange".into(),
                "client_credentials".into(),
            ],
            token_endpoint_auth_methods_supported: vec![
                "urn:cheers:client-assertion-type:paseto-v4-public".into(),
            ],
            subject_types_supported: vec!["user".into(), "service".into(), "camp".into()],
        };
        assert_eq!(cfg, expected);
    }

    #[tokio::test]
    async fn scopes_supported_equals_the_registry() {
        // A registry wider than yah's set: discovery must list exactly what
        // the deployment registered, in declaration order.
        mod extra {
            cheers_core::scopes! {
                TRIAGE = "issues:triage" {
                    description: "triage",
                    audiences: ["https://issues.example"],
                };
            }
        }
        let scopes = Arc::new(
            yah_scopes::NAMESPACES
                .iter()
                .fold(yah_scopes::builder().with(extra::DEFS), |b, ns| {
                    b.bind_audiences(ns, ["https://cheers.example".to_owned()])
                })
                .build()
                .unwrap(),
        );
        let req = Request::builder()
            .method("GET")
            .uri(OPENID_CONFIGURATION_PATH)
            .body(Body::empty())
            .unwrap();
        let resp = app_with(scopes.clone()).oneshot(req).await.unwrap();
        let cfg: OpenIdConfiguration = body_json(resp.into_body()).await;
        let from_registry: Vec<String> =
            scopes.iter().map(|d| d.scope.as_wire().to_string()).collect();
        assert_eq!(
            cfg.scopes_supported, from_registry,
            "scopes_supported must reflect the registry exactly (order and contents)",
        );
        assert_eq!(cfg.scopes_supported.len(), 19);
        assert_eq!(cfg.scopes_supported.last().map(String::as_str), Some("issues:triage"));
    }

    #[tokio::test]
    async fn grant_and_subject_types_include_the_required_values() {
        let cfg = fetch_configuration().await;
        assert!(
            cfg.grant_types_supported
                .contains(&"urn:ietf:params:oauth:grant-type:token-exchange".to_string()),
            "token-exchange grant must be advertised",
        );
        assert!(
            cfg.grant_types_supported.contains(&"client_credentials".to_string()),
            "client_credentials grant must be advertised",
        );
        assert!(
            !cfg.grant_types_supported.contains(&"passkey".to_string()),
            "POST /token serves no passkey grant, so discovery must not advertise one",
        );
        assert_eq!(
            cfg.subject_types_supported,
            vec!["user".to_string(), "service".to_string(), "camp".to_string()],
        );
    }

    #[tokio::test]
    async fn issuer_prefixes_jwks_and_token_endpoints() {
        let cfg = fetch_configuration().await;
        assert!(cfg.jwks_uri.starts_with(&cfg.issuer));
        assert!(cfg.token_endpoint.starts_with(&cfg.issuer));
        assert!(cfg.jwks_uri.ends_with(JWKS_PATH));
        assert!(cfg.token_endpoint.ends_with(TOKEN_ENDPOINT_PATH));
    }
}