dispatch.rs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. //! `POST /token` grant dispatcher (RFC 6749 §5, RFC 8693 §2, RFC 9449 §6).
  2. //!
  3. //! Parses the form once, authenticates the client through the shared
  4. //! [`client_auth`] dispatch (or delegates to the grants that authenticate
  5. //! themselves), verifies the fresh sender proof, dispatches on `grant_type`,
  6. //! and shapes the RFC 6749 §5.1 JSON response. A missing `grant_type` is
  7. //! `invalid_request`; an unknown one fails closed with `unsupported_grant_type`.
  8. //!
  9. //! The per-grant branches live in [`grants`]; this module owns the HTTP
  10. //! contract, the request-scoped handles, and the shared authentication and
  11. //! proof helpers.
  12. mod grants;
  13. use axum::Json;
  14. use axum::extract::{Extension, RawForm, State};
  15. use axum::http::{HeaderMap, HeaderValue, header};
  16. use axum::response::{IntoResponse, Response};
  17. use secrecy::SecretString;
  18. use serde::{Deserialize, Serialize};
  19. use sqlx::PgPool;
  20. use super::grants::device_code::DevicePollError;
  21. use super::{ClientKind, SenderProof, TokenService};
  22. use crate::{
  23. db::{TokenEndpointAuthMethod, client_repo, scope_repo},
  24. error::AppError,
  25. jose::{
  26. dpop::{DpopRequest, verify_dpop},
  27. jwt_access::JwtAccessSigner,
  28. },
  29. protocol::{
  30. client_auth::{self, AuthContext, AuthStores, ClientAuthRequest},
  31. mtls,
  32. scope::ScopeSet,
  33. },
  34. state::{AppState, RedisConn},
  35. store::Store,
  36. tls::PeerCertificates,
  37. };
  38. /// `POST /token` request form (RFC 6749 §4 plus every extension this server
  39. /// supports). All fields are optional here: each grant validates its own
  40. /// required members and answers `invalid_request` when one is missing.
  41. #[derive(Debug, Default, Deserialize)]
  42. pub struct TokenForm {
  43. /// The grant being exercised.
  44. pub grant_type: Option<String>,
  45. /// Authorization code (authorization-code grant).
  46. pub code: Option<String>,
  47. /// Redirect URI bound to the code (authorization-code grant).
  48. pub redirect_uri: Option<String>,
  49. /// PKCE verifier (authorization-code grant).
  50. pub code_verifier: Option<String>,
  51. /// Refresh token (refresh-token grant).
  52. pub refresh_token: Option<String>,
  53. /// Client identifier, from the form body.
  54. pub client_id: Option<String>,
  55. /// Client secret, from the form body only.
  56. pub client_secret: Option<String>,
  57. /// Requested scope, or `None` to keep the grant's scope.
  58. pub scope: Option<String>,
  59. /// Device code (device-code grant).
  60. pub device_code: Option<String>,
  61. /// Subject token (token-exchange grant).
  62. pub subject_token: Option<String>,
  63. /// Subject token type URN (token-exchange grant).
  64. pub subject_token_type: Option<String>,
  65. /// Actor token (token-exchange grant).
  66. pub actor_token: Option<String>,
  67. /// Actor token type URN (token-exchange grant).
  68. pub actor_token_type: Option<String>,
  69. /// Requested token type URN (token-exchange grant).
  70. pub requested_token_type: Option<String>,
  71. /// Token type hint (accepted for RFC 7009/7662 parity; never trusted).
  72. pub token_type_hint: Option<String>,
  73. /// `private_key_jwt` assertion type.
  74. pub client_assertion_type: Option<String>,
  75. /// `private_key_jwt` assertion.
  76. pub client_assertion: Option<String>,
  77. }
  78. impl TokenForm {
  79. /// Fields the extractor recognizes: a repeat of any of these is the
  80. /// duplicate-field rejection (mapped to `invalid_request`, per RFC 6749
  81. /// §5.2 "includes a parameter more than once"). Unknown fields (including
  82. /// a repeated `resource`) are silently absorbed, as `serde_urlencoded`
  83. /// absorbed them.
  84. const KNOWN_FIELDS: [&str; 17] = [
  85. "grant_type",
  86. "code",
  87. "redirect_uri",
  88. "code_verifier",
  89. "refresh_token",
  90. "client_id",
  91. "client_secret",
  92. "scope",
  93. "device_code",
  94. "subject_token",
  95. "subject_token_type",
  96. "actor_token",
  97. "actor_token_type",
  98. "requested_token_type",
  99. "token_type_hint",
  100. "client_assertion_type",
  101. "client_assertion",
  102. ];
  103. /// Build the form from a strict single-valued map (the `Bytes` extractor
  104. /// replacement for `Form<TokenForm>`). Every member is optional: each
  105. /// grant validates its own required members.
  106. fn from_map(map: &std::collections::HashMap<String, String>) -> Self {
  107. Self {
  108. grant_type: map.get("grant_type").cloned(),
  109. code: map.get("code").cloned(),
  110. redirect_uri: map.get("redirect_uri").cloned(),
  111. code_verifier: map.get("code_verifier").cloned(),
  112. refresh_token: map.get("refresh_token").cloned(),
  113. client_id: map.get("client_id").cloned(),
  114. client_secret: map.get("client_secret").cloned(),
  115. scope: map.get("scope").cloned(),
  116. device_code: map.get("device_code").cloned(),
  117. subject_token: map.get("subject_token").cloned(),
  118. subject_token_type: map.get("subject_token_type").cloned(),
  119. actor_token: map.get("actor_token").cloned(),
  120. actor_token_type: map.get("actor_token_type").cloned(),
  121. requested_token_type: map.get("requested_token_type").cloned(),
  122. token_type_hint: map.get("token_type_hint").cloned(),
  123. client_assertion_type: map.get("client_assertion_type").cloned(),
  124. client_assertion: map.get("client_assertion").cloned(),
  125. }
  126. }
  127. }
  128. /// RFC 6749 §5.1 token response plus the RFC 8693 §2.2.1
  129. /// `issued_token_type` member. Optional members are omitted, never null.
  130. #[derive(Debug, Serialize)]
  131. struct TokenResponseBody {
  132. /// Opaque access token.
  133. access_token: String,
  134. /// `DPoP` or `Bearer`.
  135. token_type: String,
  136. /// Access-token lifetime, seconds.
  137. expires_in: u64,
  138. /// Opaque refresh token, absent for grants that issue none.
  139. #[serde(skip_serializing_if = "Option::is_none")]
  140. refresh_token: Option<String>,
  141. /// Space-joined effective scope.
  142. scope: String,
  143. /// Token-exchange result type, absent for every other grant.
  144. #[serde(skip_serializing_if = "Option::is_none")]
  145. issued_token_type: Option<String>,
  146. }
  147. /// Token-endpoint failure: the RFC 6749 error model plus the RFC 8628 §3.5
  148. /// polling codes.
  149. enum TokenError {
  150. /// A protocol or dependency failure with its own status.
  151. OAuth(AppError),
  152. /// A device-polling failure (`authorization_pending`, `slow_down`, ...).
  153. Device(DevicePollError),
  154. }
  155. impl From<AppError> for TokenError {
  156. fn from(error: AppError) -> Self {
  157. Self::OAuth(error)
  158. }
  159. }
  160. impl From<DevicePollError> for TokenError {
  161. fn from(error: DevicePollError) -> Self {
  162. Self::Device(error)
  163. }
  164. }
  165. impl IntoResponse for TokenError {
  166. fn into_response(self) -> Response {
  167. match self {
  168. Self::OAuth(error) => error.into_response(),
  169. Self::Device(error) => error.into_response(),
  170. }
  171. }
  172. }
  173. /// Request-scoped handles shared by every grant branch.
  174. struct Dispatch<'a> {
  175. /// Shared application state.
  176. state: &'a AppState,
  177. /// Redis connection for codes, tokens, proofs, and replay markers.
  178. conn: &'a mut RedisConn,
  179. /// Key layout plus retry/durability knobs.
  180. store: &'a Store,
  181. /// Request headers (Basic credentials, `DPoP` proof).
  182. headers: &'a HeaderMap,
  183. /// Parsed form body.
  184. form: &'a TokenForm,
  185. /// Verified peer chain injected by the TLS accept loop, when present.
  186. peer_certs: Option<&'a PeerCertificates>,
  187. /// Raw repeated `resource` values (RFC 8707) from this token request, in
  188. /// wire order; empty when the request carried no `resource`. Each grant
  189. /// validates this set against the catalog AFTER authenticating the client,
  190. /// so an unauthenticated caller observes `invalid_client` first.
  191. resources: &'a [String],
  192. /// CIMD resolution settings for a registration-free fallback; `None` when
  193. /// `config.cimd_enabled` is off.
  194. cimd: Option<crate::protocol::cimd::CimdSettings<'a>>,
  195. /// Current time as seconds since the Unix epoch.
  196. now_unix: i64,
  197. }
  198. impl Dispatch<'_> {
  199. /// Authenticate the token-request client through the shared dispatch.
  200. async fn authenticate(&mut self) -> Result<client_auth::AuthenticatedClient, AppError> {
  201. let endpoint = client_auth::token_endpoint(&self.state.config.issuer);
  202. let request = ClientAuthRequest {
  203. basic_header: basic_header(self.headers),
  204. client_id: self.form.client_id.as_deref(),
  205. body_secret: self.form.client_secret.clone().map(SecretString::from),
  206. assertion_type: self.form.client_assertion_type.as_deref(),
  207. assertion: self.form.client_assertion.as_deref(),
  208. peer_certs: self.peer_certs,
  209. };
  210. let mut stores = AuthStores {
  211. pool: &self.state.pool,
  212. keys: &self.state.keys,
  213. conn: &mut *self.conn,
  214. store: self.store,
  215. };
  216. let ctx = AuthContext {
  217. token_endpoint: &endpoint,
  218. now: self.state.clock.now(),
  219. cimd: self.cimd,
  220. };
  221. client_auth::authenticate_client(&mut stores, &ctx, &request).await
  222. }
  223. /// Enforce this client's access-token format on a minting path.
  224. ///
  225. /// `opaque` returns without constructing the JWT signer or reading any key
  226. /// material; a `jwt` client whose global switch is off (or any unrecognized
  227. /// stored value) fails closed with `temporarily_unavailable` (503).
  228. fn require_token_format(&self, client: &client_repo::Client) -> Result<TokenFormat, AppError> {
  229. resolve_token_format(
  230. &client.access_token_format,
  231. self.state.config.jwt_access_tokens_enabled,
  232. )
  233. }
  234. /// Apply the format gate to a grant that authenticates inside its own
  235. /// module (`authorization_code`, `device_code`), and report the resolved
  236. /// format for the mint call.
  237. ///
  238. /// Before delegation the dispatcher only knows a candidate presenter id. An
  239. /// unknown or inactive client, or a structurally inconsistent Basic/body
  240. /// pair, is left to the grant's existing `invalid_client` path so error
  241. /// precedence does not change (`Ok(None)`: the grant never mints). A
  242. /// client the dispatcher can name is gated first, so a `jwt` client with
  243. /// the global switch off never reaches minting (and never burns a code or
  244. /// a device poll). A registration-free CIMD id resolves to `Ok(None)` here
  245. /// and to `Opaque` at the call site: the synthetic principal is always
  246. /// `opaque` by construction (a document can never opt into JWT), so no
  247. /// format gate is skipped.
  248. async fn gate_candidate_client_format(
  249. &self,
  250. client_id: Option<String>,
  251. ) -> Result<Option<TokenFormat>, AppError> {
  252. let Some(client_id) = client_id else {
  253. return Ok(None);
  254. };
  255. let Some(client) =
  256. client_repo::find_active_by_client_id(&self.state.pool, &self.state.keys, &client_id).await?
  257. else {
  258. return Ok(None);
  259. };
  260. self.require_token_format(&client).map(Some)
  261. }
  262. /// Verify this request's fresh sender proofs (DPoP `jkt`, mTLS `x5t#S256`).
  263. async fn sender_proof(&mut self, client: &client_repo::Client) -> Result<SenderProof, AppError> {
  264. let htu = client_auth::token_endpoint(&self.state.config.issuer);
  265. let dpop_jkt = match dpop_proof(self.headers) {
  266. Some(proof) => Some(
  267. verify_dpop(
  268. &mut *self.conn,
  269. self.store,
  270. &DpopRequest {
  271. proof: Some(proof),
  272. htm: "POST",
  273. htu: &htu,
  274. access_token: None,
  275. now_unix: self.now_unix,
  276. },
  277. )
  278. .await?
  279. .jkt,
  280. ),
  281. None => None,
  282. };
  283. let mtls_x5t_s256 = match client.auth_method() {
  284. Ok(
  285. TokenEndpointAuthMethod::TlsClientAuth | TokenEndpointAuthMethod::SelfSignedTlsClientAuth,
  286. ) => {
  287. let leaf = self.peer_certs.and_then(|certs| certs.chain().first());
  288. Some(
  289. mtls::authenticate(&self.state.pool, &self.state.keys, client, leaf)
  290. .await
  291. .map_err(mtls::MtlsError::into_app_error)?
  292. .x5t_s256,
  293. )
  294. }
  295. _ => None,
  296. };
  297. Ok(SenderProof {
  298. dpop_jkt,
  299. mtls_x5t_s256,
  300. })
  301. }
  302. }
  303. /// The raw `Authorization` header, when present and UTF-8.
  304. fn basic_header(headers: &HeaderMap) -> Option<&str> {
  305. headers
  306. .get(header::AUTHORIZATION)
  307. .and_then(|value| value.to_str().ok())
  308. }
  309. /// The raw `DPoP` proof header, when present and UTF-8.
  310. fn dpop_proof(headers: &HeaderMap) -> Option<&str> {
  311. headers.get("dpop").and_then(|value| value.to_str().ok())
  312. }
  313. /// Re-exported from [`super::types`], where the decision type lives so both
  314. /// this dispatcher and [`super::service`] share it without a module cycle.
  315. pub use super::types::TokenFormat;
  316. /// Resolve a client's stored `access_token_format` to a [`TokenFormat`].
  317. ///
  318. /// `opaque` is the default and never consults the JWT stack. RFC 9068 §2 makes
  319. /// JWT access tokens an opt-in profile the authorization server decides per
  320. /// client; §4 ties issuance to signing-key material, so a `jwt` client is
  321. /// accepted only while the operator's global `jwt_access_tokens_enabled`
  322. /// switch is on. When that switch is off — or the stored value is unknown
  323. /// (corruption or a future value) — the request fails closed with
  324. /// `temporarily_unavailable` (HTTP 503): the client asked for a token shape
  325. /// this deployment cannot mint, so there is no silent opaque fallback and no
  326. /// partial token. It is deliberately not `server_error` (HTTP 500), which
  327. /// would retract the fail-closed dependency contract, and not a 4xx, which
  328. /// would blame the client for a deployment state.
  329. fn resolve_token_format(
  330. stored: &str,
  331. jwt_access_tokens_enabled: bool,
  332. ) -> Result<TokenFormat, AppError> {
  333. match stored {
  334. "opaque" => Ok(TokenFormat::Opaque),
  335. "jwt" if jwt_access_tokens_enabled => Ok(TokenFormat::Jwt),
  336. "jwt" => Err(AppError::temporarily_unavailable()),
  337. _ => Err(AppError::temporarily_unavailable()),
  338. }
  339. }
  340. /// Public vs confidential: the sender-constraint matrix selector.
  341. fn client_kind(client: &client_repo::Client) -> Result<ClientKind, AppError> {
  342. match client.auth_method() {
  343. Ok(TokenEndpointAuthMethod::None) => Ok(ClientKind::Public),
  344. Ok(_) => Ok(ClientKind::Confidential),
  345. Err(_) => Err(AppError::invalid_client_non_basic()),
  346. }
  347. }
  348. /// The client must be registered for the grant it is exercising.
  349. fn require_grant(client: &client_repo::Client, grant: &str) -> Result<(), AppError> {
  350. if client
  351. .grant_types
  352. .iter()
  353. .any(|registered| registered == grant)
  354. {
  355. Ok(())
  356. } else {
  357. Err(AppError::unauthorized_client(
  358. "client may not use this grant",
  359. ))
  360. }
  361. }
  362. /// A client registered for DPoP-bound tokens must present a fresh proof.
  363. ///
  364. /// Shared with the grants that authenticate their client internally
  365. /// (`authorization_code`, `device_code`) so the registration flag is enforced
  366. /// on every token-time path, not only the dispatcher-authenticated ones.
  367. pub(super) fn require_dpop_binding(
  368. client: &client_repo::Client,
  369. proof: &SenderProof,
  370. ) -> Result<(), AppError> {
  371. let proven = proof.dpop_jkt.as_deref().is_some_and(|jkt| !jkt.is_empty());
  372. if client.dpop_bound_access_tokens && !proven {
  373. Err(AppError::invalid_grant(
  374. "DPoP proof is required for this client",
  375. ))
  376. } else {
  377. Ok(())
  378. }
  379. }
  380. /// The enabled scope catalog as a set.
  381. async fn enabled_scopes(pool: &PgPool) -> Result<ScopeSet, AppError> {
  382. let names: Vec<String> = scope_repo::list_enabled(pool)
  383. .await?
  384. .into_iter()
  385. .map(|row| row.name)
  386. .collect();
  387. ScopeSet::from_names(&names)
  388. }
  389. /// Build the token service from the validated TTLs and key ring.
  390. fn token_service(state: &AppState) -> Result<TokenService, AppError> {
  391. TokenService::new(&state.config.ttl, &state.keys).map_err(AppError::from)
  392. }
  393. /// Build the JWT signer for a `Jwt` mint from the validated config: the
  394. /// active `jwt_signing_key` signs, previous keys verify only. Fail-closed
  395. /// `temporarily_unavailable` when no key is configured (the format gate
  396. /// guarantees this is unreachable in practice): never a 500, and the typed
  397. /// [`crate::jose::jwt_access::JwtError`] carries no key material.
  398. fn jwt_signer(state: &AppState) -> Result<JwtAccessSigner, AppError> {
  399. JwtAccessSigner::from_config(&state.config).map_err(|_| AppError::temporarily_unavailable())
  400. }
  401. /// `POST /token`: the five-grant dispatcher.
  402. ///
  403. /// # Errors
  404. ///
  405. /// Returns the RFC 6749 §5.2 error for the failing grant (or the RFC 8628
  406. /// §3.5 polling code), always with the correct HTTP status.
  407. pub async fn handler(
  408. State(state): State<AppState>,
  409. peer_certs: Option<Extension<PeerCertificates>>,
  410. headers: HeaderMap,
  411. body: RawForm,
  412. ) -> Response {
  413. // `RawForm` gates the content type BEFORE `Bytes` buffering, so a wrong
  414. // content type is rejected 415 without reading the (up to 2 MiB) body.
  415. // The explicit exact-MIME gate below is the second check: `RawForm` uses a
  416. // case-sensitive prefix match, so it accepts e.g.
  417. // `application/x-www-form-urlencodedevil`, which the exact gate rejects.
  418. let body = body.0;
  419. // `RawForm` + a manual parse replaces `Result<Form<TokenForm>, FormRejection>`:
  420. // the extractor consumed the raw body, hiding repeats. The explicit
  421. // exact-MIME gate below is the second content-type check (see above); any
  422. // other transport failure keeps the documented `invalid_request` mapping
  423. // ("includes a parameter more than once"). The repeated `resource`
  424. // indicator (RFC 8707) is captured in order by `params::values_of` and
  425. // carried raw on the dispatcher: each grant validates it against the
  426. // catalog AFTER authenticating the client, so an unauthenticated caller
  427. // observes `invalid_client` before any catalog error.
  428. if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
  429. return reject.into_response();
  430. }
  431. let malformed = || {
  432. AppError::invalid_request(
  433. "the request body is malformed or includes a parameter more than once",
  434. )
  435. };
  436. let text = match std::str::from_utf8(&body) {
  437. Ok(text) => text,
  438. Err(_) => return TokenError::from(malformed()).into_response(),
  439. };
  440. let pairs = match crate::protocol::params::parse_pairs(text) {
  441. Ok(pairs) => pairs,
  442. Err(_) => return TokenError::from(malformed()).into_response(),
  443. };
  444. if crate::protocol::params::has_duplicate_field(&pairs, &TokenForm::KNOWN_FIELDS) {
  445. return TokenError::from(malformed()).into_response();
  446. }
  447. let map = crate::protocol::params::single_valued_map(&pairs);
  448. let form = TokenForm::from_map(&map);
  449. let resources =
  450. crate::protocol::params::values_of(&pairs, crate::protocol::params::RESOURCE_PARAM);
  451. // RFC 8707 resource indicators do not apply to RFC 8693 token exchange;
  452. // reject the parameter BEFORE the catalog lookup so a bogus value cannot
  453. // surface `invalid_target` from a grant that must answer `invalid_request`.
  454. if form.grant_type.as_deref()
  455. == Some(crate::protocol::token::grants::token_exchange::GRANT_TYPE_TOKEN_EXCHANGE)
  456. && !resources.is_empty()
  457. {
  458. return TokenError::from(AppError::invalid_request(
  459. "resource is not supported by token-exchange",
  460. ))
  461. .into_response();
  462. }
  463. let mut conn = state.redis.clone();
  464. let store = Store::from_config(&state.config.redis);
  465. let now_unix = state.clock.now().unix_timestamp();
  466. let mut dispatch = Dispatch {
  467. state: &state,
  468. conn: &mut conn,
  469. store: &store,
  470. headers: &headers,
  471. form: &form,
  472. peer_certs: peer_certs.as_deref(),
  473. resources: &resources,
  474. cimd: state
  475. .config
  476. .cimd_enabled
  477. .then(|| crate::protocol::cimd::CimdSettings {
  478. token_prefix: &state.config.redis.token_key_prefix,
  479. max_bytes: state.config.cimd_max_bytes,
  480. timeout: state.config.cimd_fetch_timeout,
  481. allow_localhost: state.config.allow_localhost_redirect,
  482. }),
  483. now_unix,
  484. };
  485. match grants::dispatch_grant(&mut dispatch).await {
  486. Ok(body) => token_response(body),
  487. Err(error) => error.into_response(),
  488. }
  489. }
  490. /// Shape a success response: JSON plus the RFC 6749 §5.1 no-store headers.
  491. fn token_response(body: TokenResponseBody) -> Response {
  492. let mut response = Json(body).into_response();
  493. response
  494. .headers_mut()
  495. .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
  496. response
  497. .headers_mut()
  498. .insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
  499. response
  500. }
  501. #[cfg(test)]
  502. mod tests {
  503. use axum::http::StatusCode;
  504. use super::{TokenFormat, resolve_token_format};
  505. #[test]
  506. fn unknown_format_fails_closed_503() {
  507. let unknown = resolve_token_format("bogus", true)
  508. .err()
  509. .map(|error| error.status());
  510. assert_eq!(unknown, Some(StatusCode::SERVICE_UNAVAILABLE));
  511. let jwt_off = resolve_token_format("jwt", false)
  512. .err()
  513. .map(|error| error.status());
  514. assert_eq!(jwt_off, Some(StatusCode::SERVICE_UNAVAILABLE));
  515. assert!(matches!(
  516. resolve_token_format("opaque", false),
  517. Ok(TokenFormat::Opaque)
  518. ));
  519. assert!(matches!(
  520. resolve_token_format("opaque", true),
  521. Ok(TokenFormat::Opaque)
  522. ));
  523. assert!(matches!(
  524. resolve_token_format("jwt", true),
  525. Ok(TokenFormat::Jwt)
  526. ));
  527. }
  528. }