//! Authorization-server discovery (RFC 8414 with OIDC fallback). //! //! [`discover`] fetches `{issuer}/.well-known/oauth-authorization-server` and, //! when that document is absent (`404`), falls back to //! `{issuer}/.well-known/openid-configuration`. The parsed [`ServerMetadata`] //! exposes every endpoint verbatim — callers must use these URLs and never //! construct endpoint URLs manually. //! //! [`ServerMetadata::capabilities`] derives the [`Capabilities`] flags the rest //! of the client (and the `server` crate) degrade on. //! //! # Refresh rule //! //! `refresh` is true when `grant_types_supported` contains `refresh_token`. The //! field is OPTIONAL per RFC 8414 §2, so when it is absent we default to //! `true`: absence conveys no restriction, and the token endpoint still fails //! closed with `invalid_grant` if refresh is unsupported. //! //! # Retry rule //! //! Only `503 Service Unavailable` is retried ([`MAX_ATTEMPTS`] total tries with //! exponential backoff from [`RETRY_BASE_DELAY`]). Any other status is handled //! immediately (`404` on the RFC 8414 path triggers the OIDC fallback; other //! failures become [`OAuthError::Discovery`]). Exhausting the budget surfaces //! the typed [`OAuthError::DiscoveryUnavailable`]. //! //! # Shared HTTP client contract //! //! [`build_client`] is the single reqwest construction point for `oauth-core` //! and the `server` crate (todos 11/12 reuse it for token/introspection): plain //! `rustls-tls`, no custom roots, no `danger_accept_invalid_certs`. TLS trust //! customization (custom CA / dev `insecure_tls`) lives in the `server` crate //! (todo 19), not here. use std::time::Duration; use serde::Deserialize; use crate::{ config::Capabilities, error::{OAuthError, Result}, }; /// Total GET attempts per well-known URL before giving up (initial + retries). pub const MAX_ATTEMPTS: u32 = 3; /// Base delay for the 503 backoff; attempt `n` (1-based) waits `base * 2^(n-1)`. pub const RETRY_BASE_DELAY: Duration = Duration::from_millis(20); /// Build the shared reqwest client for `oauth-core` protocol calls. /// /// Plain `rustls-tls`, JSON + form support, no `native-tls`. Timeouts are set /// so a hung AS fails fast instead of stalling the BFF. pub fn build_client() -> Result { reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() .map_err(|err| OAuthError::Http { reason: format!("failed to build http client: {err}"), }) } /// Authorization-server metadata (RFC 8414 §2 / OIDC Discovery). /// /// Every endpoint is `Option`: a compliant doc always carries /// `authorization_endpoint` and `token_endpoint`, but capability derivation /// keys off presence, so absence must be representable rather than a parse /// failure. #[derive(Clone, Debug, Default, Deserialize)] #[serde(default)] pub struct ServerMetadata { /// The issuer identifier the document was fetched for. pub issuer: String, /// RFC 8414 §2 / OIDC: authorization endpoint. pub authorization_endpoint: Option, /// RFC 8414 §2 / OIDC: token endpoint. pub token_endpoint: Option, /// RFC 9126: pushed authorization request endpoint. pub pushed_authorization_request_endpoint: Option, /// RFC 7662: introspection endpoint. pub introspection_endpoint: Option, /// RFC 7009: revocation endpoint. pub revocation_endpoint: Option, /// RFC 7591: dynamic client registration endpoint. pub registration_endpoint: Option, /// OIDC Discovery: userinfo endpoint (d-OAuth omits it). pub userinfo_endpoint: Option, /// RFC 9449: DPoP proof signing algorithms accepted by the AS. pub dpop_signing_alg_values_supported: Option>, /// Scope catalog advertised by the AS. pub scopes_supported: Option>, /// Grant types the token endpoint accepts (optional per RFC 8414 §2). pub grant_types_supported: Option>, } impl ServerMetadata { /// Derive [`Capabilities`] from endpoint/algorithm presence. /// /// - `par` = PAR endpoint present. /// - `dpop` = `dpop_signing_alg_values_supported` contains `ES256`. /// - `introspection` / `userinfo` / `dcr` = respective endpoint present. /// - `refresh` = `grant_types_supported` contains `refresh_token` when the /// field is present, else `true` (see the module-level refresh rule). pub fn capabilities(&self) -> Capabilities { Capabilities { par: self.pushed_authorization_request_endpoint.is_some(), dpop: self .dpop_signing_alg_values_supported .as_deref() .is_some_and(|algs| algs.iter().any(|alg| alg == "ES256")), introspection: self.introspection_endpoint.is_some(), userinfo: self.userinfo_endpoint.is_some(), dcr: self.registration_endpoint.is_some(), refresh: self .grant_types_supported .as_deref() .is_none_or(|grants| grants.iter().any(|grant| grant == "refresh_token")), } } } /// Run RFC 8414 discovery against `issuer` with the OIDC fallback. /// /// Uses [`build_client`] internally. Prefer [`discover_with_client`] when the /// caller already holds the shared client. pub async fn discover(issuer: &str) -> Result { let client = build_client()?; discover_with_client(&client, issuer).await } /// Run RFC 8414 discovery against `issuer` with `client`. /// /// Tries `{issuer}/.well-known/oauth-authorization-server` first; on `404` /// falls back to `{issuer}/.well-known/openid-configuration`. A document that /// parses but carries no `token_endpoint` is a [`OAuthError::Discovery`] /// failure — endpoint URLs are never synthesized. pub async fn discover_with_client( client: &reqwest::Client, issuer: &str, ) -> Result { let issuer = issuer.trim_end_matches('/'); let oauth_url = format!("{issuer}/.well-known/oauth-authorization-server"); match get_metadata(client, issuer, &oauth_url).await? { Some(metadata) => Ok(metadata), None => { let oidc_url = format!("{issuer}/.well-known/openid-configuration"); get_metadata(client, issuer, &oidc_url) .await? .ok_or_else(|| OAuthError::Discovery { issuer: issuer.to_owned(), reason: "no RFC 8414 or OIDC discovery document found".to_owned(), }) } } } /// GET one well-known URL with bounded 503 retry. /// /// Returns `Ok(None)` only for `404` (absent document → caller falls back or /// reports). `503` is retried up to [`MAX_ATTEMPTS`]; exhaustion yields /// [`OAuthError::DiscoveryUnavailable`]. async fn get_metadata( client: &reqwest::Client, issuer: &str, url: &str, ) -> Result> { let mut attempt: u32 = 0; loop { attempt += 1; let response = client.get(url).send().await.map_err(|err| { if err.is_timeout() || err.is_connect() { OAuthError::Discovery { issuer: issuer.to_owned(), reason: format!("discovery request failed: {err}"), } } else { OAuthError::Http { reason: format!("discovery request failed: {err}"), } } })?; let status = response.status(); if status.as_u16() == 503 && attempt < MAX_ATTEMPTS { let backoff = RETRY_BASE_DELAY * 2_u32.pow(attempt - 1); tokio::time::sleep(backoff).await; continue; } if status.as_u16() == 503 { return Err(OAuthError::DiscoveryUnavailable { reason: format!("discovery endpoint at {url} still unavailable after {attempt} attempts"), }); } if status.as_u16() == 404 { return Ok(None); } if !status.is_success() { return Err(OAuthError::Discovery { issuer: issuer.to_owned(), reason: format!("discovery endpoint returned status {status}"), }); } let metadata: ServerMetadata = response.json().await.map_err(|err| OAuthError::Discovery { issuer: issuer.to_owned(), reason: format!("discovery document is not valid metadata: {err}"), })?; if metadata.token_endpoint.is_none() { return Err(OAuthError::Discovery { issuer: issuer.to_owned(), reason: "discovery document omits token_endpoint".to_owned(), }); } return Ok(Some(metadata)); } } #[cfg(test)] mod tests { use serde_json::json; use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{method, path}, }; use super::*; const OAUTH_PATH: &str = "/.well-known/oauth-authorization-server"; const OIDC_PATH: &str = "/.well-known/openid-configuration"; /// d-OAuth-shaped metadata: PAR + introspection + DPoP ES256, no userinfo. fn d_oauth_doc(issuer: &str) -> serde_json::Value { json!({ "issuer": issuer, "authorization_endpoint": format!("{issuer}/authorize"), "token_endpoint": format!("{issuer}/token"), "registration_endpoint": format!("{issuer}/register"), "introspection_endpoint": format!("{issuer}/introspect"), "revocation_endpoint": format!("{issuer}/revoke"), "pushed_authorization_request_endpoint": format!("{issuer}/par"), "response_types_supported": ["code"], "grant_types_supported": [ "authorization_code", "refresh_token", "client_credentials", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange" ], "code_challenge_methods_supported": ["S256"], "dpop_signing_alg_values_supported": ["ES256", "EdDSA", "RS256"], "scopes_supported": ["demo.read"] }) } fn oidc_doc(issuer: &str) -> serde_json::Value { json!({ "issuer": issuer, "authorization_endpoint": format!("{issuer}/authorize"), "token_endpoint": format!("{issuer}/token"), "userinfo_endpoint": format!("{issuer}/userinfo"), "grant_types_supported": ["authorization_code", "client_credentials"], "code_challenge_methods_supported": ["S256"], "dpop_signing_alg_values_supported": ["RS256"], "scopes_supported": ["openid", "profile"] }) } async fn mock_json(server: &MockServer, route: &str, status: u16, body: serde_json::Value) { Mock::given(method("GET")) .and(path(route)) .respond_with(ResponseTemplate::new(status).set_body_json(body)) .mount(server) .await; } #[tokio::test] async fn d_oauth_doc_enables_par_introspection_and_dpop() { let server = MockServer::start().await; let issuer = server.uri(); mock_json(&server, OAUTH_PATH, 200, d_oauth_doc(&issuer)).await; let client = build_client().unwrap(); let metadata = discover_with_client(&client, &issuer).await.unwrap(); let capabilities = metadata.capabilities(); assert_eq!(metadata.token_endpoint, Some(format!("{issuer}/token"))); assert!(capabilities.par); assert!(capabilities.introspection); assert!(capabilities.dpop); assert!(!capabilities.userinfo); assert!(capabilities.dcr); assert!(capabilities.refresh); assert_eq!( metadata.scopes_supported, Some(vec!["demo.read".to_owned()]) ); } #[tokio::test] async fn oidc_fallback_doc_disables_par_and_dpop() { let server = MockServer::start().await; let issuer = server.uri(); Mock::given(method("GET")) .and(path(OAUTH_PATH)) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; mock_json(&server, OIDC_PATH, 200, oidc_doc(&issuer)).await; let client = build_client().unwrap(); let metadata = discover_with_client(&client, &issuer).await.unwrap(); let capabilities = metadata.capabilities(); assert!(!capabilities.par); assert!(!capabilities.dpop); assert!(capabilities.userinfo); // Grant list present without refresh_token → refresh unsupported. assert!(!capabilities.refresh); } #[tokio::test] async fn doc_without_par_disables_par_but_keeps_refresh_default() { let server = MockServer::start().await; let issuer = server.uri(); let mut doc = d_oauth_doc(&issuer); doc .as_object_mut() .unwrap() .remove("pushed_authorization_request_endpoint"); doc.as_object_mut().unwrap().remove("grant_types_supported"); mock_json(&server, OAUTH_PATH, 200, doc).await; let client = build_client().unwrap(); let metadata = discover_with_client(&client, &issuer).await.unwrap(); let capabilities = metadata.capabilities(); assert!(!capabilities.par); assert!(capabilities.refresh); assert!(capabilities.dpop); } #[tokio::test] async fn flaky_503_then_200_succeeds() { let server = MockServer::start().await; let issuer = server.uri(); Mock::given(method("GET")) .and(path(OAUTH_PATH)) .respond_with(ResponseTemplate::new(503)) .up_to_n_times(1) .mount(&server) .await; mock_json(&server, OAUTH_PATH, 200, d_oauth_doc(&issuer)).await; let client = build_client().unwrap(); let metadata = discover_with_client(&client, &issuer).await.unwrap(); assert_eq!(metadata.token_endpoint, Some(format!("{issuer}/token"))); } #[tokio::test] async fn persistent_503_returns_discovery_unavailable() { let server = MockServer::start().await; let issuer = server.uri(); Mock::given(method("GET")) .and(path(OAUTH_PATH)) .respond_with(ResponseTemplate::new(503)) .mount(&server) .await; let client = build_client().unwrap(); let result = discover_with_client(&client, &issuer).await; assert!( matches!(result, Err(OAuthError::DiscoveryUnavailable { .. })), "expected DiscoveryUnavailable, got {result:?}" ); } #[tokio::test] async fn missing_both_docs_is_a_typed_discovery_error() { let server = MockServer::start().await; let issuer = server.uri(); Mock::given(method("GET")) .and(path(OAUTH_PATH)) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; Mock::given(method("GET")) .and(path(OIDC_PATH)) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; let client = build_client().unwrap(); let result = discover_with_client(&client, &issuer).await; assert!( matches!(result, Err(OAuthError::Discovery { .. })), "expected Discovery, got {result:?}" ); } #[tokio::test] async fn doc_without_token_endpoint_is_a_typed_discovery_error() { let server = MockServer::start().await; let issuer = server.uri(); let mut doc = d_oauth_doc(&issuer); doc.as_object_mut().unwrap().remove("token_endpoint"); mock_json(&server, OAUTH_PATH, 200, doc).await; let client = build_client().unwrap(); let result = discover_with_client(&client, &issuer).await; assert!( matches!(result, Err(OAuthError::Discovery { .. })), "a doc without a token endpoint is unusable, got {result:?}" ); } }