discovery.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. //! Authorization-server discovery (RFC 8414 with OIDC fallback).
  2. //!
  3. //! [`discover`] fetches `{issuer}/.well-known/oauth-authorization-server` and,
  4. //! when that document is absent (`404`), falls back to
  5. //! `{issuer}/.well-known/openid-configuration`. The parsed [`ServerMetadata`]
  6. //! exposes every endpoint verbatim — callers must use these URLs and never
  7. //! construct endpoint URLs manually.
  8. //!
  9. //! [`ServerMetadata::capabilities`] derives the [`Capabilities`] flags the rest
  10. //! of the client (and the `server` crate) degrade on.
  11. //!
  12. //! # Refresh rule
  13. //!
  14. //! `refresh` is true when `grant_types_supported` contains `refresh_token`. The
  15. //! field is OPTIONAL per RFC 8414 §2, so when it is absent we default to
  16. //! `true`: absence conveys no restriction, and the token endpoint still fails
  17. //! closed with `invalid_grant` if refresh is unsupported.
  18. //!
  19. //! # Retry rule
  20. //!
  21. //! Only `503 Service Unavailable` is retried ([`MAX_ATTEMPTS`] total tries with
  22. //! exponential backoff from [`RETRY_BASE_DELAY`]). Any other status is handled
  23. //! immediately (`404` on the RFC 8414 path triggers the OIDC fallback; other
  24. //! failures become [`OAuthError::Discovery`]). Exhausting the budget surfaces
  25. //! the typed [`OAuthError::DiscoveryUnavailable`].
  26. //!
  27. //! # Shared HTTP client contract
  28. //!
  29. //! [`build_client`] is the single reqwest construction point for `oauth-core`
  30. //! and the `server` crate (todos 11/12 reuse it for token/introspection): plain
  31. //! `rustls-tls`, no custom roots, no `danger_accept_invalid_certs`. TLS trust
  32. //! customization (custom CA / dev `insecure_tls`) lives in the `server` crate
  33. //! (todo 19), not here.
  34. use std::time::Duration;
  35. use serde::Deserialize;
  36. use crate::{
  37. config::Capabilities,
  38. error::{OAuthError, Result},
  39. };
  40. /// Total GET attempts per well-known URL before giving up (initial + retries).
  41. pub const MAX_ATTEMPTS: u32 = 3;
  42. /// Base delay for the 503 backoff; attempt `n` (1-based) waits `base * 2^(n-1)`.
  43. pub const RETRY_BASE_DELAY: Duration = Duration::from_millis(20);
  44. /// Build the shared reqwest client for `oauth-core` protocol calls.
  45. ///
  46. /// Plain `rustls-tls`, JSON + form support, no `native-tls`. Timeouts are set
  47. /// so a hung AS fails fast instead of stalling the BFF.
  48. pub fn build_client() -> Result<reqwest::Client> {
  49. reqwest::Client::builder()
  50. .timeout(Duration::from_secs(10))
  51. .build()
  52. .map_err(|err| OAuthError::Http {
  53. reason: format!("failed to build http client: {err}"),
  54. })
  55. }
  56. /// Authorization-server metadata (RFC 8414 §2 / OIDC Discovery).
  57. ///
  58. /// Every endpoint is `Option`: a compliant doc always carries
  59. /// `authorization_endpoint` and `token_endpoint`, but capability derivation
  60. /// keys off presence, so absence must be representable rather than a parse
  61. /// failure.
  62. #[derive(Clone, Debug, Default, Deserialize)]
  63. #[serde(default)]
  64. pub struct ServerMetadata {
  65. /// The issuer identifier the document was fetched for.
  66. pub issuer: String,
  67. /// RFC 8414 §2 / OIDC: authorization endpoint.
  68. pub authorization_endpoint: Option<String>,
  69. /// RFC 8414 §2 / OIDC: token endpoint.
  70. pub token_endpoint: Option<String>,
  71. /// RFC 9126: pushed authorization request endpoint.
  72. pub pushed_authorization_request_endpoint: Option<String>,
  73. /// RFC 7662: introspection endpoint.
  74. pub introspection_endpoint: Option<String>,
  75. /// RFC 7009: revocation endpoint.
  76. pub revocation_endpoint: Option<String>,
  77. /// RFC 7591: dynamic client registration endpoint.
  78. pub registration_endpoint: Option<String>,
  79. /// OIDC Discovery: userinfo endpoint (d-OAuth omits it).
  80. pub userinfo_endpoint: Option<String>,
  81. /// RFC 9449: DPoP proof signing algorithms accepted by the AS.
  82. pub dpop_signing_alg_values_supported: Option<Vec<String>>,
  83. /// Scope catalog advertised by the AS.
  84. pub scopes_supported: Option<Vec<String>>,
  85. /// Grant types the token endpoint accepts (optional per RFC 8414 §2).
  86. pub grant_types_supported: Option<Vec<String>>,
  87. }
  88. impl ServerMetadata {
  89. /// Derive [`Capabilities`] from endpoint/algorithm presence.
  90. ///
  91. /// - `par` = PAR endpoint present.
  92. /// - `dpop` = `dpop_signing_alg_values_supported` contains `ES256`.
  93. /// - `introspection` / `userinfo` / `dcr` = respective endpoint present.
  94. /// - `refresh` = `grant_types_supported` contains `refresh_token` when the
  95. /// field is present, else `true` (see the module-level refresh rule).
  96. pub fn capabilities(&self) -> Capabilities {
  97. Capabilities {
  98. par: self.pushed_authorization_request_endpoint.is_some(),
  99. dpop: self
  100. .dpop_signing_alg_values_supported
  101. .as_deref()
  102. .is_some_and(|algs| algs.iter().any(|alg| alg == "ES256")),
  103. introspection: self.introspection_endpoint.is_some(),
  104. userinfo: self.userinfo_endpoint.is_some(),
  105. dcr: self.registration_endpoint.is_some(),
  106. refresh: self
  107. .grant_types_supported
  108. .as_deref()
  109. .is_none_or(|grants| grants.iter().any(|grant| grant == "refresh_token")),
  110. }
  111. }
  112. }
  113. /// Run RFC 8414 discovery against `issuer` with the OIDC fallback.
  114. ///
  115. /// Uses [`build_client`] internally. Prefer [`discover_with_client`] when the
  116. /// caller already holds the shared client.
  117. pub async fn discover(issuer: &str) -> Result<ServerMetadata> {
  118. let client = build_client()?;
  119. discover_with_client(&client, issuer).await
  120. }
  121. /// Run RFC 8414 discovery against `issuer` with `client`.
  122. ///
  123. /// Tries `{issuer}/.well-known/oauth-authorization-server` first; on `404`
  124. /// falls back to `{issuer}/.well-known/openid-configuration`. A document that
  125. /// parses but carries no `token_endpoint` is a [`OAuthError::Discovery`]
  126. /// failure — endpoint URLs are never synthesized.
  127. pub async fn discover_with_client(
  128. client: &reqwest::Client,
  129. issuer: &str,
  130. ) -> Result<ServerMetadata> {
  131. let issuer = issuer.trim_end_matches('/');
  132. let oauth_url = format!("{issuer}/.well-known/oauth-authorization-server");
  133. match get_metadata(client, issuer, &oauth_url).await? {
  134. Some(metadata) => Ok(metadata),
  135. None => {
  136. let oidc_url = format!("{issuer}/.well-known/openid-configuration");
  137. get_metadata(client, issuer, &oidc_url)
  138. .await?
  139. .ok_or_else(|| OAuthError::Discovery {
  140. issuer: issuer.to_owned(),
  141. reason: "no RFC 8414 or OIDC discovery document found".to_owned(),
  142. })
  143. }
  144. }
  145. }
  146. /// GET one well-known URL with bounded 503 retry.
  147. ///
  148. /// Returns `Ok(None)` only for `404` (absent document → caller falls back or
  149. /// reports). `503` is retried up to [`MAX_ATTEMPTS`]; exhaustion yields
  150. /// [`OAuthError::DiscoveryUnavailable`].
  151. async fn get_metadata(
  152. client: &reqwest::Client,
  153. issuer: &str,
  154. url: &str,
  155. ) -> Result<Option<ServerMetadata>> {
  156. let mut attempt: u32 = 0;
  157. loop {
  158. attempt += 1;
  159. let response = client.get(url).send().await.map_err(|err| {
  160. if err.is_timeout() || err.is_connect() {
  161. OAuthError::Discovery {
  162. issuer: issuer.to_owned(),
  163. reason: format!("discovery request failed: {err}"),
  164. }
  165. } else {
  166. OAuthError::Http {
  167. reason: format!("discovery request failed: {err}"),
  168. }
  169. }
  170. })?;
  171. let status = response.status();
  172. if status.as_u16() == 503 && attempt < MAX_ATTEMPTS {
  173. let backoff = RETRY_BASE_DELAY * 2_u32.pow(attempt - 1);
  174. tokio::time::sleep(backoff).await;
  175. continue;
  176. }
  177. if status.as_u16() == 503 {
  178. return Err(OAuthError::DiscoveryUnavailable {
  179. reason: format!("discovery endpoint at {url} still unavailable after {attempt} attempts"),
  180. });
  181. }
  182. if status.as_u16() == 404 {
  183. return Ok(None);
  184. }
  185. if !status.is_success() {
  186. return Err(OAuthError::Discovery {
  187. issuer: issuer.to_owned(),
  188. reason: format!("discovery endpoint returned status {status}"),
  189. });
  190. }
  191. let metadata: ServerMetadata = response.json().await.map_err(|err| OAuthError::Discovery {
  192. issuer: issuer.to_owned(),
  193. reason: format!("discovery document is not valid metadata: {err}"),
  194. })?;
  195. if metadata.token_endpoint.is_none() {
  196. return Err(OAuthError::Discovery {
  197. issuer: issuer.to_owned(),
  198. reason: "discovery document omits token_endpoint".to_owned(),
  199. });
  200. }
  201. return Ok(Some(metadata));
  202. }
  203. }
  204. #[cfg(test)]
  205. mod tests {
  206. use serde_json::json;
  207. use wiremock::{
  208. Mock, MockServer, ResponseTemplate,
  209. matchers::{method, path},
  210. };
  211. use super::*;
  212. const OAUTH_PATH: &str = "/.well-known/oauth-authorization-server";
  213. const OIDC_PATH: &str = "/.well-known/openid-configuration";
  214. /// d-OAuth-shaped metadata: PAR + introspection + DPoP ES256, no userinfo.
  215. fn d_oauth_doc(issuer: &str) -> serde_json::Value {
  216. json!({
  217. "issuer": issuer,
  218. "authorization_endpoint": format!("{issuer}/authorize"),
  219. "token_endpoint": format!("{issuer}/token"),
  220. "registration_endpoint": format!("{issuer}/register"),
  221. "introspection_endpoint": format!("{issuer}/introspect"),
  222. "revocation_endpoint": format!("{issuer}/revoke"),
  223. "pushed_authorization_request_endpoint": format!("{issuer}/par"),
  224. "response_types_supported": ["code"],
  225. "grant_types_supported": [
  226. "authorization_code",
  227. "refresh_token",
  228. "client_credentials",
  229. "urn:ietf:params:oauth:grant-type:device_code",
  230. "urn:ietf:params:oauth:grant-type:token-exchange"
  231. ],
  232. "code_challenge_methods_supported": ["S256"],
  233. "dpop_signing_alg_values_supported": ["ES256", "EdDSA", "RS256"],
  234. "scopes_supported": ["demo.read"]
  235. })
  236. }
  237. fn oidc_doc(issuer: &str) -> serde_json::Value {
  238. json!({
  239. "issuer": issuer,
  240. "authorization_endpoint": format!("{issuer}/authorize"),
  241. "token_endpoint": format!("{issuer}/token"),
  242. "userinfo_endpoint": format!("{issuer}/userinfo"),
  243. "grant_types_supported": ["authorization_code", "client_credentials"],
  244. "code_challenge_methods_supported": ["S256"],
  245. "dpop_signing_alg_values_supported": ["RS256"],
  246. "scopes_supported": ["openid", "profile"]
  247. })
  248. }
  249. async fn mock_json(server: &MockServer, route: &str, status: u16, body: serde_json::Value) {
  250. Mock::given(method("GET"))
  251. .and(path(route))
  252. .respond_with(ResponseTemplate::new(status).set_body_json(body))
  253. .mount(server)
  254. .await;
  255. }
  256. #[tokio::test]
  257. async fn d_oauth_doc_enables_par_introspection_and_dpop() {
  258. let server = MockServer::start().await;
  259. let issuer = server.uri();
  260. mock_json(&server, OAUTH_PATH, 200, d_oauth_doc(&issuer)).await;
  261. let client = build_client().unwrap();
  262. let metadata = discover_with_client(&client, &issuer).await.unwrap();
  263. let capabilities = metadata.capabilities();
  264. assert_eq!(metadata.token_endpoint, Some(format!("{issuer}/token")));
  265. assert!(capabilities.par);
  266. assert!(capabilities.introspection);
  267. assert!(capabilities.dpop);
  268. assert!(!capabilities.userinfo);
  269. assert!(capabilities.dcr);
  270. assert!(capabilities.refresh);
  271. assert_eq!(
  272. metadata.scopes_supported,
  273. Some(vec!["demo.read".to_owned()])
  274. );
  275. }
  276. #[tokio::test]
  277. async fn oidc_fallback_doc_disables_par_and_dpop() {
  278. let server = MockServer::start().await;
  279. let issuer = server.uri();
  280. Mock::given(method("GET"))
  281. .and(path(OAUTH_PATH))
  282. .respond_with(ResponseTemplate::new(404))
  283. .mount(&server)
  284. .await;
  285. mock_json(&server, OIDC_PATH, 200, oidc_doc(&issuer)).await;
  286. let client = build_client().unwrap();
  287. let metadata = discover_with_client(&client, &issuer).await.unwrap();
  288. let capabilities = metadata.capabilities();
  289. assert!(!capabilities.par);
  290. assert!(!capabilities.dpop);
  291. assert!(capabilities.userinfo);
  292. // Grant list present without refresh_token → refresh unsupported.
  293. assert!(!capabilities.refresh);
  294. }
  295. #[tokio::test]
  296. async fn doc_without_par_disables_par_but_keeps_refresh_default() {
  297. let server = MockServer::start().await;
  298. let issuer = server.uri();
  299. let mut doc = d_oauth_doc(&issuer);
  300. doc
  301. .as_object_mut()
  302. .unwrap()
  303. .remove("pushed_authorization_request_endpoint");
  304. doc.as_object_mut().unwrap().remove("grant_types_supported");
  305. mock_json(&server, OAUTH_PATH, 200, doc).await;
  306. let client = build_client().unwrap();
  307. let metadata = discover_with_client(&client, &issuer).await.unwrap();
  308. let capabilities = metadata.capabilities();
  309. assert!(!capabilities.par);
  310. assert!(capabilities.refresh);
  311. assert!(capabilities.dpop);
  312. }
  313. #[tokio::test]
  314. async fn flaky_503_then_200_succeeds() {
  315. let server = MockServer::start().await;
  316. let issuer = server.uri();
  317. Mock::given(method("GET"))
  318. .and(path(OAUTH_PATH))
  319. .respond_with(ResponseTemplate::new(503))
  320. .up_to_n_times(1)
  321. .mount(&server)
  322. .await;
  323. mock_json(&server, OAUTH_PATH, 200, d_oauth_doc(&issuer)).await;
  324. let client = build_client().unwrap();
  325. let metadata = discover_with_client(&client, &issuer).await.unwrap();
  326. assert_eq!(metadata.token_endpoint, Some(format!("{issuer}/token")));
  327. }
  328. #[tokio::test]
  329. async fn persistent_503_returns_discovery_unavailable() {
  330. let server = MockServer::start().await;
  331. let issuer = server.uri();
  332. Mock::given(method("GET"))
  333. .and(path(OAUTH_PATH))
  334. .respond_with(ResponseTemplate::new(503))
  335. .mount(&server)
  336. .await;
  337. let client = build_client().unwrap();
  338. let result = discover_with_client(&client, &issuer).await;
  339. assert!(
  340. matches!(result, Err(OAuthError::DiscoveryUnavailable { .. })),
  341. "expected DiscoveryUnavailable, got {result:?}"
  342. );
  343. }
  344. #[tokio::test]
  345. async fn missing_both_docs_is_a_typed_discovery_error() {
  346. let server = MockServer::start().await;
  347. let issuer = server.uri();
  348. Mock::given(method("GET"))
  349. .and(path(OAUTH_PATH))
  350. .respond_with(ResponseTemplate::new(404))
  351. .mount(&server)
  352. .await;
  353. Mock::given(method("GET"))
  354. .and(path(OIDC_PATH))
  355. .respond_with(ResponseTemplate::new(404))
  356. .mount(&server)
  357. .await;
  358. let client = build_client().unwrap();
  359. let result = discover_with_client(&client, &issuer).await;
  360. assert!(
  361. matches!(result, Err(OAuthError::Discovery { .. })),
  362. "expected Discovery, got {result:?}"
  363. );
  364. }
  365. #[tokio::test]
  366. async fn doc_without_token_endpoint_is_a_typed_discovery_error() {
  367. let server = MockServer::start().await;
  368. let issuer = server.uri();
  369. let mut doc = d_oauth_doc(&issuer);
  370. doc.as_object_mut().unwrap().remove("token_endpoint");
  371. mock_json(&server, OAUTH_PATH, 200, doc).await;
  372. let client = build_client().unwrap();
  373. let result = discover_with_client(&client, &issuer).await;
  374. assert!(
  375. matches!(result, Err(OAuthError::Discovery { .. })),
  376. "a doc without a token endpoint is unusable, got {result:?}"
  377. );
  378. }
  379. }