| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416 |
- //! 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> {
- 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<String>,
- /// RFC 8414 §2 / OIDC: token endpoint.
- pub token_endpoint: Option<String>,
- /// RFC 9126: pushed authorization request endpoint.
- pub pushed_authorization_request_endpoint: Option<String>,
- /// RFC 7662: introspection endpoint.
- pub introspection_endpoint: Option<String>,
- /// RFC 7009: revocation endpoint.
- pub revocation_endpoint: Option<String>,
- /// RFC 7591: dynamic client registration endpoint.
- pub registration_endpoint: Option<String>,
- /// OIDC Discovery: userinfo endpoint (d-OAuth omits it).
- pub userinfo_endpoint: Option<String>,
- /// RFC 9449: DPoP proof signing algorithms accepted by the AS.
- pub dpop_signing_alg_values_supported: Option<Vec<String>>,
- /// Scope catalog advertised by the AS.
- pub scopes_supported: Option<Vec<String>>,
- /// Grant types the token endpoint accepts (optional per RFC 8414 §2).
- pub grant_types_supported: Option<Vec<String>>,
- }
- 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<ServerMetadata> {
- 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<ServerMetadata> {
- 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<Option<ServerMetadata>> {
- 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:?}"
- );
- }
- }
|