JwksKeyStore; everything downstream of the decode (classification, the email ladder, claimcrates/temper-services/src/state.rs (JwksKeyStore),crates/temper-api/src/middleware/auth.rs, crates/temper-mcp/src/middleware.rs.JwksKeyStore fetches the RSA public key from Auth0's JWKS.JwksKeyStore fetches the OKP key from the AS's /oauth/jwks.client_credentials token is not a separate key family, only a separate claim shape. SoJwksKeyStore supports both key families and maps each to its algorithm:jsonwebtoken's verify_signature rejects anyValidation whose algorithm allow-list contains a family the loaded key does not match. Soget_decoding_key() returns a VerificationKey {
key, algorithm }, and validation(issuer, audience, algorithm) scopes the allow-list toJwksKeyStore::new); tests preload a static key viawith_static_key.config.auth_audiencemcp_config.mcp_audience, parsed separately from MCP_AUDIENCE with aAUTH_AUDIENCE. That is gone.| issuer | audience | |
|---|---|---|
| temper-api | config.auth.issuer | config.auth.audience |
| temper-mcp | config.auth.issuer (same) | config.auth.audience (the same) |
jwks_store.validation(issuer, audience, alg) — note audience: &str, notOption<&str>. An instance has exactly one audience, parsed once at boot.AUTH_AUDIENCE made temper-api set validate_aud = false and accept everything, while an emptyMCP_AUDIENCE made temper-mcp enforce aud == "" and reject everything. One typo, two oppositeset_audienceis not sufficient on its own.jsonwebtokenonly compares the audience when
theaudclaim is present —required_spec_claimsdefaults to{"exp"}. A token omittingaudentirely was accepted even withvalidate_aud = true.validation()therefore setsrequired_spec_claims(&["exp", "iss", "aud"]). Requiring the value to match without requiring
the claim to exist closes half a door.
temper_services::auth::RawJwtClaims — a superset struct whose optional fields (email,email_verified, azp, gty) absorb the human/machine shape difference — and hands the seamRawJwtClaims | the decoded claims, exactly as verified |
the raw bearer &str | needed by one rung of the email ladder — the /userinfo call presents the token itself, not its claims |
authenticate_token(&state, &raw, token). The surface does notAuthClaims — the seam is the only constructor. On temper-mcp the two values travelRawJwtClaims + BearerToken (a newtype, so it cannot becrates/temper-services/src/auth/email.rs. It used to live in temper-api's middleware, andemail: String::new() and auto-provisioned. It now runs foremail claim embedded in the token (a custom Auth0 Action can add it), elsekb_profile_auth_links (from a prior login), else/userinfo endpoint (discovered once per process via/.well-known/openid-configuration) as a last resort.AuthzError::EmailResolution → a 401 (HTTP) / a terminalINVALID_REQUEST (MCP): a human we cannot name is a human we will not provision. The/userinfo to ask, so running it on that path would be an authentication failure dressed as aparse_auth_configtemper-services/src/auth_config.rs) — an instance that violates any of them refuses to start,AS_ISSUER set,AS_ISSUER flips theAuthMode::TemperAs).AUTH_AUDIENCE is mandatory. Empty counts as unset. It used to resolve to None, which setvalidate_aud = false and disabled audience validation outright; there is no longer anOption to carry that state.AS_AUDIENCE == AUTH_AUDIENCE, AS_ISSUER == AUTH_ISSUER,JWKS_URL == $AS_ISSUER/oauth/jwks (trailing slashes normalized before comparison).temper admin saml provision keeps them consistent by construction. Details inmcp_audience field is gone, and both surfaces read AuthConfig::audience. MCP_AUDIENCEAUTH_AUDIENCE, orMCP_AUDIENCE ?? AUTH_AUDIENCE fallback, andAS_AUDIENCE, ignoring any request-supplied audience (mint.ts). There isMCP_AUDIENCE would make/mcp. That is now unreachable rather than merely discouraged.