Skip to main content

authplane_sdk/
oauth.rs

1use reqwest::Client;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4
5use crate::constants::{
6    auth_schemes, http_headers, http_methods, introspection_fields, jwt_claims, media_types,
7};
8use crate::errors::{
9    AuthError, AuthplaneError, map_oauth_error, protocol_error, transport_error, validation_error,
10};
11use crate::fetch_settings::FetchSettings;
12use crate::transport::validate_fetch_url;
13
14pub const GRANT_TYPE_TOKEN_EXCHANGE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
15pub const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token";
16
17#[derive(Debug, Clone, PartialEq, Eq, Default)]
18pub struct TokenExchangeOptions {
19    pub subject_token: String,
20    pub subject_token_type: String,
21    pub actor_token: String,
22    pub actor_token_type: String,
23    pub scope: String,
24    pub resources: Vec<String>,
25    pub audiences: Vec<String>,
26}
27
28impl TokenExchangeOptions {
29    pub fn normalized(&self) -> Self {
30        let mut normalized = self.clone();
31        normalized.resources.retain(|value| !value.is_empty());
32        normalized.audiences.retain(|value| !value.is_empty());
33        normalized
34    }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(from = "TokenResponseWire")]
39#[non_exhaustive]
40pub struct TokenResponse {
41    // Field-level `#[serde(default)]` is intentionally omitted: deserialization
42    // is routed through `TokenResponseWire` by `#[serde(from)]` above, and the
43    // wire type carries its own per-field defaults. Re-declaring `default` here
44    // would be inert (dead for deserialize, ignored for serialize).
45    pub access_token: String,
46    pub token_type: String,
47    /// AS-supplied lifetime hint in seconds (RFC 6749 §5.1).
48    ///
49    /// * `None` — the AS omitted the field. The cache layer applies its
50    ///   configured `default_ttl`.
51    /// * `Some(0)` — the AS asked for immediate expiry (RFC 6749 §5.1
52    ///   permits this for one-shot flows). The cache refuses to store.
53    /// * `Some(n)` with `n > 0` — use `n` seconds, then apply the buffer.
54    ///
55    /// On the wire, `None` is omitted entirely (the field is absent from
56    /// the JSON), matching what the AS would have sent — so serializing a
57    /// cached `TokenResponse` round-trips through the same parse path
58    /// that produced it.
59    ///
60    /// ```
61    /// # use authplane_sdk::oauth::TokenResponse;
62    /// # fn dispatch(resp: TokenResponse) {
63    /// match resp.expires_in {
64    ///     None => { /* AS gave no hint — apply default TTL */ }
65    ///     Some(0) => { /* explicit immediate expiry — do not cache */ }
66    ///     Some(_n) => { /* use n seconds */ }
67    /// }
68    /// # }
69    /// ```
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub expires_in: Option<i64>,
72    pub scope: String,
73    pub refresh_token: String,
74    pub issued_token_type: String,
75    /// Raw `cnf` (confirmation) object from the token response when
76    /// present. RFC 9449 §6.1 places `cnf.jkt` here for DPoP-bound
77    /// tokens; this field preserves any extension members
78    /// (`x5t#S256`, future additions) verbatim. Only `Some` when the
79    /// AS sent a JSON object; non-object `cnf` values are dropped.
80    pub cnf: Option<Value>,
81    /// Convenience accessor for the DPoP key thumbprint at
82    /// `cnf.jkt` (RFC 9449 §6.1). Empty string when the token is
83    /// not DPoP-bound. Always derived from `cnf.jkt` on deserialize
84    /// via `#[serde(from = "TokenResponseWire")]`, matching the
85    /// imperative `parse_token_response_inner` path through
86    /// `extract_cnf_and_jkt` — a top-level `cnf_jkt` on the wire is
87    /// not honoured, so a poisoned blob that disagrees with its own
88    /// `cnf` object cannot mint a mismatched thumbprint.
89    pub cnf_jkt: String,
90}
91
92/// Private wire shape for [`TokenResponse`]: derived `Deserialize`. The
93/// public type's `#[serde(from)]` runs the `cnf.jkt` → `cnf_jkt`
94/// derivation in `From<TokenResponseWire>` so constructed and
95/// deserialized values agree on the binding. A top-level `cnf_jkt` on
96/// the wire is intentionally absent from this struct: it is ignored by
97/// serde's default unknown-field policy so the thumbprint is always
98/// derived from `cnf` and the two constructors stay symmetric.
99#[derive(Deserialize)]
100struct TokenResponseWire {
101    access_token: String,
102    token_type: String,
103    #[serde(default, deserialize_with = "deserialize_optional_expires_in")]
104    expires_in: Option<i64>,
105    scope: String,
106    #[serde(default)]
107    refresh_token: String,
108    #[serde(default)]
109    issued_token_type: String,
110    #[serde(default)]
111    cnf: Option<Value>,
112}
113
114impl From<TokenResponseWire> for TokenResponse {
115    fn from(wire: TokenResponseWire) -> Self {
116        let (cnf, cnf_jkt) = normalize_cnf(wire.cnf);
117        Self {
118            access_token: wire.access_token,
119            token_type: wire.token_type,
120            expires_in: wire.expires_in,
121            scope: wire.scope,
122            refresh_token: wire.refresh_token,
123            issued_token_type: wire.issued_token_type,
124            cnf,
125            cnf_jkt,
126        }
127    }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(from = "IntrospectionResponseWire")]
132#[non_exhaustive]
133pub struct IntrospectionResponse {
134    // Field-level `#[serde(default)]` is intentionally omitted here for the
135    // same reason as on [`TokenResponse`]: deserialization is routed through
136    // `IntrospectionResponseWire` by `#[serde(from)]` above.
137    pub active: bool,
138    pub scope: String,
139    pub client_id: String,
140    pub sub: String,
141    pub token_type: String,
142    pub iss: String,
143    pub aud: Option<Value>,
144    pub exp: Option<i64>,
145    pub iat: Option<i64>,
146    pub jti: String,
147    pub agent_id: String,
148    pub agent_chain: Vec<String>,
149    /// Raw `cnf` (confirmation) object from the RFC 7662 introspection
150    /// response. RFC 9449 §6.2 places the DPoP key thumbprint at
151    /// `cnf.jkt`; this field preserves the full object so callers can
152    /// read extension members (`x5t#S256`, future additions). Only
153    /// `Some` when the AS sent a JSON object; non-object `cnf` values
154    /// are dropped to keep the typed shape honest.
155    pub cnf: Option<Value>,
156    /// Convenience accessor for the DPoP key thumbprint at
157    /// `cnf.jkt` (RFC 9449 §6.2 / RFC 7662). Empty string when
158    /// absent. Always derived from `cnf.jkt` on deserialize via
159    /// `#[serde(from = "IntrospectionResponseWire")]` — see the
160    /// matching note on [`TokenResponse::cnf_jkt`].
161    pub cnf_jkt: String,
162}
163
164/// Private wire shape for [`IntrospectionResponse`]. A top-level
165/// `cnf_jkt` on the wire is intentionally absent: it is ignored by
166/// serde's default unknown-field policy so the thumbprint is always
167/// derived from `cnf` and the imperative `introspect_token` path
168/// (via `extract_cnf_and_jkt`) agrees byte-for-byte with the
169/// `From<IntrospectionResponseWire>` impl.
170#[derive(Deserialize)]
171struct IntrospectionResponseWire {
172    active: bool,
173    #[serde(default)]
174    scope: String,
175    #[serde(default)]
176    client_id: String,
177    #[serde(default)]
178    sub: String,
179    #[serde(default)]
180    token_type: String,
181    #[serde(default)]
182    iss: String,
183    #[serde(default)]
184    aud: Option<Value>,
185    #[serde(default)]
186    exp: Option<i64>,
187    #[serde(default)]
188    iat: Option<i64>,
189    #[serde(default)]
190    jti: String,
191    #[serde(default)]
192    agent_id: String,
193    #[serde(default)]
194    agent_chain: Vec<String>,
195    #[serde(default)]
196    cnf: Option<Value>,
197}
198
199impl From<IntrospectionResponseWire> for IntrospectionResponse {
200    fn from(wire: IntrospectionResponseWire) -> Self {
201        let (cnf, cnf_jkt) = normalize_cnf(wire.cnf);
202        Self {
203            active: wire.active,
204            scope: wire.scope,
205            client_id: wire.client_id,
206            sub: wire.sub,
207            token_type: wire.token_type,
208            iss: wire.iss,
209            aud: wire.aud,
210            exp: wire.exp,
211            iat: wire.iat,
212            jti: wire.jti,
213            agent_id: wire.agent_id,
214            agent_chain: wire.agent_chain,
215            cnf,
216            cnf_jkt,
217        }
218    }
219}
220
221pub fn parse_token_exchange_error(status_code: Option<u16>, body: &str) -> AuthplaneError {
222    let parsed: Result<Value, _> = serde_json::from_str(body);
223    match parsed {
224        Ok(payload) => map_oauth_error(status_code, &payload),
225        Err(_) => AuthplaneError::Auth(AuthError {
226            message: "OAuth request failed".to_string(),
227            code: "invalid_response".to_string(),
228            status_code,
229        }),
230    }
231}
232
233/// Parse an OAuth 2.0 token response.
234///
235/// When `expect_dpop` is `true` (a DPoP proof was sent with the request),
236/// the response `token_type` **must** be `"DPoP"` per RFC 9449 §5. If the
237/// AS returns `"Bearer"` instead, this function returns a `ProtocolError`
238/// so the caller knows the proof was silently ignored.
239pub fn parse_token_response(
240    data: &Value,
241    allow_issued_token_type: bool,
242) -> Result<TokenResponse, AuthplaneError> {
243    parse_token_response_inner(data, allow_issued_token_type, false)
244}
245
246/// Same as [`parse_token_response`] with DPoP-type enforcement.
247pub fn parse_token_response_dpop(
248    data: &Value,
249    allow_issued_token_type: bool,
250) -> Result<TokenResponse, AuthplaneError> {
251    parse_token_response_inner(data, allow_issued_token_type, true)
252}
253
254fn parse_token_response_inner(
255    data: &Value,
256    allow_issued_token_type: bool,
257    expect_dpop: bool,
258) -> Result<TokenResponse, AuthplaneError> {
259    let access_token = required_string(data, "access_token")?;
260    let token_type = required_string(data, "token_type")?;
261    if !token_type.eq_ignore_ascii_case(auth_schemes::BEARER)
262        && !token_type.eq_ignore_ascii_case(auth_schemes::DPOP)
263    {
264        return Err(protocol_error(&format!(
265            "unsupported token_type {token_type:?}; only {} and {} are supported",
266            auth_schemes::BEARER,
267            auth_schemes::DPOP
268        )));
269    }
270    if expect_dpop && !token_type.eq_ignore_ascii_case(auth_schemes::DPOP) {
271        return Err(protocol_error(&format!(
272            "DPoP proof was sent but token_type is {token_type:?}, not {:?}; \
273             the authorization server may have ignored the DPoP proof (RFC 9449 §5)",
274            auth_schemes::DPOP
275        )));
276    }
277
278    let issued_token_type = optional_string(data, "issued_token_type");
279    if allow_issued_token_type {
280        if issued_token_type.is_empty() {
281            return Err(protocol_error(
282                "token exchange response missing required 'issued_token_type' (RFC 8693 §2.2.1)",
283            ));
284        }
285        if issued_token_type != TOKEN_TYPE_ACCESS_TOKEN {
286            return Err(protocol_error(&format!(
287                "unsupported issued_token_type {issued_token_type:?}; only access_token is supported"
288            )));
289        }
290    }
291
292    let (cnf, cnf_jkt) = extract_cnf_and_jkt(data);
293
294    Ok(TokenResponse {
295        access_token,
296        token_type,
297        expires_in: optional_non_negative_i64(data, "expires_in")?,
298        scope: optional_string(data, "scope"),
299        refresh_token: optional_string(data, "refresh_token"),
300        issued_token_type,
301        cnf,
302        cnf_jkt,
303    })
304}
305
306pub async fn client_credentials_grant(
307    http: &Client,
308    token_endpoint: &str,
309    auth_header: &str,
310    fetch_settings: &FetchSettings,
311    scopes: &[String],
312    resources: &[String],
313    dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
314) -> Result<TokenResponse, AuthplaneError> {
315    let form = build_client_credentials_form(scopes, resources);
316
317    let expect_dpop = dpop_provider.is_some();
318    let (status, payload) = form_post_with_dpop(
319        http,
320        token_endpoint,
321        "token endpoint",
322        &form,
323        auth_header,
324        fetch_settings,
325        dpop_provider,
326        // No access token to bind via `ath` — this call IS the one
327        // that mints it. AS endpoints don't require `ath` per
328        // RFC 9449 §4.2 (ath is for resource-server requests).
329        None,
330    )
331    .await?;
332
333    if crate::transport::is_http_success(status) {
334        return if expect_dpop {
335            parse_token_response_dpop(&payload, false)
336        } else {
337            parse_token_response(&payload, false)
338        };
339    }
340
341    Err(map_oauth_error(Some(status), &payload))
342}
343
344pub async fn exchange_token(
345    http: &Client,
346    token_endpoint: &str,
347    options: &TokenExchangeOptions,
348    auth_header: &str,
349    fetch_settings: &FetchSettings,
350    dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
351) -> Result<TokenResponse, AuthplaneError> {
352    if options.subject_token.is_empty() {
353        return Err(validation_error("subject_token is required"));
354    }
355
356    let normalized = options.normalized();
357    let form = build_token_exchange_form(&normalized);
358
359    let expect_dpop = dpop_provider.is_some();
360    let (status, payload) = form_post_with_dpop(
361        http,
362        token_endpoint,
363        "token endpoint",
364        &form,
365        auth_header,
366        fetch_settings,
367        dpop_provider,
368        None,
369    )
370    .await?;
371
372    if crate::transport::is_http_success(status) {
373        return if expect_dpop {
374            parse_token_response_dpop(&payload, true)
375        } else {
376            parse_token_response(&payload, true)
377        };
378    }
379
380    Err(map_oauth_error(Some(status), &payload))
381}
382
383/// RFC 7662 §2.1 introspection call.
384///
385/// `auth_header` is sent verbatim; an empty string sends no credentials
386/// (RFC 7662 leaves the authentication method to the deployment). Against
387/// authserver >= 0.1.2 that path is not useful: only the issuing client or
388/// a runtime-client of the Resource named in `aud` gets a real answer, and
389/// an unauthenticated or public-client caller receives `{"active": false}`
390/// for every token. Resource servers should introspect with confidential
391/// credentials via [`crate::RevocationConfig`].
392pub async fn introspect_token(
393    http: &Client,
394    introspection_endpoint: &str,
395    token: &str,
396    auth_header: &str,
397    fetch_settings: &FetchSettings,
398    dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
399) -> Result<IntrospectionResponse, AuthplaneError> {
400    let form = [
401        (
402            crate::constants::oauth_params::TOKEN.to_string(),
403            token.to_string(),
404        ),
405        (
406            crate::constants::oauth_params::TOKEN_TYPE_HINT.to_string(),
407            crate::constants::oauth_errors::TOKEN_TYPE_HINT_ACCESS_TOKEN.to_string(),
408        ),
409    ];
410    let (status, payload) = form_post_with_dpop(
411        http,
412        introspection_endpoint,
413        "introspection endpoint",
414        &form,
415        auth_header,
416        fetch_settings,
417        dpop_provider,
418        None,
419    )
420    .await?;
421
422    if crate::transport::is_http_success(status) {
423        let (cnf, cnf_jkt) = extract_cnf_and_jkt(&payload);
424        return Ok(IntrospectionResponse {
425            active: payload
426                .get(introspection_fields::ACTIVE)
427                .and_then(Value::as_bool)
428                .unwrap_or(false),
429            scope: optional_string(&payload, introspection_fields::SCOPE),
430            client_id: optional_string(&payload, jwt_claims::CLIENT_ID),
431            sub: optional_string(&payload, jwt_claims::SUB),
432            token_type: optional_string(&payload, introspection_fields::TOKEN_TYPE),
433            iss: optional_string(&payload, jwt_claims::ISS),
434            aud: payload.get(jwt_claims::AUD).cloned(),
435            exp: payload.get(jwt_claims::EXP).and_then(Value::as_i64),
436            iat: payload.get(jwt_claims::IAT).and_then(Value::as_i64),
437            jti: optional_string(&payload, jwt_claims::JTI),
438            agent_id: optional_string(&payload, jwt_claims::AGENT_ID),
439            agent_chain: crate::json_util::string_array(&payload, jwt_claims::AGENT_CHAIN),
440            cnf,
441            cnf_jkt,
442        });
443    }
444
445    Err(map_oauth_error(Some(status), &payload))
446}
447
448pub async fn revoke_token(
449    http: &Client,
450    revocation_endpoint: &str,
451    token: &str,
452    auth_header: &str,
453    fetch_settings: &FetchSettings,
454    dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
455) -> Result<(), AuthplaneError> {
456    let form = [
457        (
458            crate::constants::oauth_params::TOKEN.to_string(),
459            token.to_string(),
460        ),
461        (
462            crate::constants::oauth_params::TOKEN_TYPE_HINT.to_string(),
463            crate::constants::oauth_errors::TOKEN_TYPE_HINT_ACCESS_TOKEN.to_string(),
464        ),
465    ];
466    let (status, payload) = form_post_with_dpop(
467        http,
468        revocation_endpoint,
469        "revocation endpoint",
470        &form,
471        auth_header,
472        fetch_settings,
473        dpop_provider,
474        None,
475    )
476    .await?;
477
478    if crate::transport::is_http_success(status) {
479        return Ok(());
480    }
481
482    Err(map_oauth_error(Some(status), &payload))
483}
484
485fn required_string(data: &Value, key: &str) -> Result<String, AuthplaneError> {
486    let value = optional_string(data, key);
487    if value.is_empty() {
488        return Err(protocol_error(&format!(
489            "token response missing required field {key:?}"
490        )));
491    }
492    Ok(value)
493}
494
495fn optional_string(data: &Value, key: &str) -> String {
496    data.get(key)
497        .and_then(Value::as_str)
498        .unwrap_or_default()
499        .trim()
500        .to_string()
501}
502
503/// Extract the raw `cnf` confirmation object and its `jkt` thumbprint from
504/// an OAuth response payload. Non-object `cnf` values are dropped so the
505/// typed shape stays honest; absent `cnf.jkt` collapses to an empty string,
506/// matching the `#[serde(default)]` contract on the field itself.
507///
508/// Convenience over [`normalize_cnf`] for the imperative `parse_*` paths
509/// that already hold the full payload as a `Value`.
510fn extract_cnf_and_jkt(payload: &Value) -> (Option<Value>, String) {
511    normalize_cnf(payload.get("cnf").cloned())
512}
513
514/// Shared `(Option<Value>, String)` derivation for an already-extracted
515/// `cnf` field. Used by both [`extract_cnf_and_jkt`] (which pulls `cnf`
516/// off a full payload) and the `From<*Wire>` impls (which receive `cnf`
517/// directly from serde). Non-object values are dropped; missing `jkt`
518/// becomes the empty string.
519fn normalize_cnf(cnf: Option<Value>) -> (Option<Value>, String) {
520    let cnf = cnf.filter(Value::is_object);
521    let cnf_jkt = cnf
522        .as_ref()
523        .and_then(Value::as_object)
524        .and_then(|map| map.get("jkt"))
525        .and_then(Value::as_str)
526        .unwrap_or_default()
527        .to_string();
528    (cnf, cnf_jkt)
529}
530
531/// Parse a non-negative integer field that may legitimately be absent.
532///
533/// Returns `None` when the field is missing or JSON `null` (the AS did not
534/// supply the hint), `Some(N)` for a present value including an explicit
535/// `Some(0)` (RFC 6749 §5.1 permits `expires_in: 0` for one-shot flows).
536/// Negative values and non-integer / non-string types are rejected.
537/// Errors the shared parse helper can emit. Stringly-typed so each caller
538/// wraps the message into its own error type without leaking
539/// `AuthplaneError` into the `serde` path.
540enum ParseOptionalI64Error {
541    NotAnInteger { got: Option<String> },
542    Negative,
543}
544
545/// Parses a JSON value as an optional non-negative `i64`, accepting both
546/// JSON numbers and stringly-encoded numerics. Shared by the
547/// `parse_token_response` path and the `serde(deserialize_with = ...)`
548/// shim so both entry points apply the same validation.
549///
550/// * Returns `Ok(None)` when the value is `Value::Null` (or absent).
551/// * Returns `Ok(Some(n))` for `n ≥ 0`.
552/// * Returns `Err` for any non-integer / non-string shape and for
553///   negative values.
554fn parse_optional_non_negative_i64(value: &Value) -> Result<Option<i64>, ParseOptionalI64Error> {
555    if value.is_null() {
556        return Ok(None);
557    }
558    let parsed = if let Some(number) = value.as_i64() {
559        number
560    } else if let Some(text) = value.as_str() {
561        text.parse::<i64>()
562            .map_err(|_| ParseOptionalI64Error::NotAnInteger {
563                got: Some(text.to_string()),
564            })?
565    } else {
566        return Err(ParseOptionalI64Error::NotAnInteger { got: None });
567    };
568    if parsed < 0 {
569        return Err(ParseOptionalI64Error::Negative);
570    }
571    Ok(Some(parsed))
572}
573
574/// `parse_token_response`-side adapter — wraps
575/// [`parse_optional_non_negative_i64`] errors as protocol errors with
576/// the offending field name.
577fn optional_non_negative_i64(data: &Value, key: &str) -> Result<Option<i64>, AuthplaneError> {
578    let value = data.get(key).unwrap_or(&Value::Null);
579    parse_optional_non_negative_i64(value).map_err(|err| match err {
580        ParseOptionalI64Error::NotAnInteger { got: Some(text) } => protocol_error(&format!(
581            "token response field {key:?} must be an integer, got {text:?}"
582        )),
583        ParseOptionalI64Error::NotAnInteger { got: None } => {
584            protocol_error(&format!("token response field {key:?} must be an integer"))
585        }
586        ParseOptionalI64Error::Negative => protocol_error(&format!(
587            "token response field {key:?} must be non-negative"
588        )),
589    })
590}
591
592/// `serde(deserialize_with = ...)` shim — wraps
593/// [`parse_optional_non_negative_i64`] errors as serde errors so callers
594/// who deserialize a `TokenResponse` directly (e.g. from a cached JSON
595/// payload) get the same validation as `parse_token_response` from a
596/// fresh AS response.
597fn deserialize_optional_expires_in<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
598where
599    D: serde::Deserializer<'de>,
600{
601    use serde::de::Error;
602    let raw: Option<Value> = Option::deserialize(deserializer)?;
603    let value = raw.unwrap_or(Value::Null);
604    parse_optional_non_negative_i64(&value).map_err(|err| match err {
605        ParseOptionalI64Error::NotAnInteger { got: Some(text) } => {
606            D::Error::custom(format!("expires_in must be an integer, got {text:?}"))
607        }
608        ParseOptionalI64Error::NotAnInteger { got: None } => {
609            D::Error::custom("expires_in must be an integer")
610        }
611        ParseOptionalI64Error::Negative => D::Error::custom("expires_in must be non-negative"),
612    })
613}
614
615/// Send a form POST with optional DPoP proof and automatic nonce retry.
616///
617/// If a `DpopProvider` is supplied, the function:
618/// 1. Builds a DPoP proof for the endpoint (using the provider's
619///    current per-origin nonce, if any),
620/// 2. Sends the request,
621/// 3. Extracts `DPoP-Nonce` from the response headers and stores it
622///    on the provider,
623/// 4. If the AS returned `use_dpop_nonce` with a fresh nonce on the
624///    error response (RFC 9449 §6.1), retries the request once with
625///    a proof rebuilt using the new nonce. A single retry is enough:
626///    once the provider has the AS nonce, subsequent calls reuse it
627///    via `current_nonce`.
628///
629/// Returns `(status, payload)`. The single retry is intentionally
630/// not exposed as a knob; servers that send `use_dpop_nonce`
631/// repeatedly are out-of-spec and we surface the error to the
632/// caller rather than loop.
633#[allow(clippy::too_many_arguments)] // each arg is load-bearing for the §6.1 retry contract
634pub(crate) async fn form_post_with_dpop(
635    http: &Client,
636    url: &str,
637    url_label: &str,
638    form: &[(String, String)],
639    auth_header: &str,
640    fetch_settings: &FetchSettings,
641    dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
642    dpop_access_token: Option<&str>,
643) -> Result<(u16, Value), AuthplaneError> {
644    validate_fetch_url(url, fetch_settings, url_label)?;
645
646    let initial_dpop = match dpop_provider {
647        Some(provider) => Some(provider.build_proof(http_methods::POST, url, dpop_access_token)?),
648        None => None,
649    };
650
651    let (status, payload, nonce) =
652        do_form_post(http, url, form, auth_header, initial_dpop.as_deref()).await?;
653
654    // Store DPoP-Nonce from response.
655    if let Some(provider) = dpop_provider {
656        if !nonce.is_empty() {
657            let _ = provider.note_nonce(url, &nonce);
658        }
659
660        // Retry on use_dpop_nonce error (RFC 9449 §6.1).
661        let error_code = payload
662            .get("error")
663            .and_then(Value::as_str)
664            .unwrap_or_default();
665        if error_code == crate::constants::oauth_errors::USE_DPOP_NONCE && !nonce.is_empty() {
666            let retry_proof = provider.build_proof(http_methods::POST, url, dpop_access_token)?;
667            let (status2, payload2, nonce2) =
668                do_form_post(http, url, form, auth_header, Some(&retry_proof)).await?;
669            // Also refresh the stored nonce from the retry response so the
670            // next outbound call starts with the freshest server-issued value.
671            // RFC 9449 §6.1 permits the AS to rotate nonces on every response;
672            // absorbing the retry's `DPoP-Nonce` here avoids a second
673            // guaranteed-stale request when the AS rotates aggressively.
674            if !nonce2.is_empty() {
675                let _ = provider.note_nonce(url, &nonce2);
676            }
677            return Ok((status2, payload2));
678        }
679    }
680
681    Ok((status, payload))
682}
683
684/// Low-level form POST. Returns (status, body, dpop_nonce_header).
685async fn do_form_post(
686    http: &Client,
687    url: &str,
688    form: &[(String, String)],
689    auth_header: &str,
690    dpop_proof: Option<&str>,
691) -> Result<(u16, Value, String), AuthplaneError> {
692    let mut request = http
693        .post(url)
694        .header(http_headers::AUTHORIZATION, auth_header)
695        .header(http_headers::ACCEPT, media_types::APPLICATION_JSON);
696    if let Some(proof) = dpop_proof {
697        request = request.header(http_headers::DPOP, proof);
698    }
699    let response = request
700        .form(form)
701        .send()
702        .await
703        .map_err(|error| transport_error(&error.to_string()))?;
704    let status = response.status().as_u16();
705    let nonce = response
706        .headers()
707        .get(http_headers::DPOP_NONCE)
708        .and_then(|v| v.to_str().ok())
709        .unwrap_or_default()
710        .to_string();
711    // RFC 7009 §2.2 revocation responses are typically 200 with no body, and a
712    // server can send an empty success body on any of these form-POST endpoints.
713    // Treat an empty body as `{}` rather than erroring on `serde_json::from_slice`
714    // so a spec-conformant revoke doesn't surface as `Err(transport_error)`.
715    let bytes = response
716        .bytes()
717        .await
718        .map_err(|error| transport_error(&error.to_string()))?;
719    let payload: Value = if bytes.is_empty() {
720        Value::Object(serde_json::Map::new())
721    } else {
722        serde_json::from_slice(&bytes).map_err(|error| transport_error(&error.to_string()))?
723    };
724    Ok((status, payload, nonce))
725}
726
727#[doc(hidden)]
728pub fn build_client_credentials_form(
729    scopes: &[String],
730    resources: &[String],
731) -> Vec<(String, String)> {
732    use crate::constants::oauth_params::*;
733    let mut form = vec![(
734        GRANT_TYPE.to_string(),
735        GRANT_TYPE_CLIENT_CREDENTIALS.to_string(),
736    )];
737    if !scopes.is_empty() {
738        form.push((SCOPE.to_string(), scopes.join(" ")));
739    }
740    for resource in resources.iter().filter(|value| !value.is_empty()) {
741        form.push((RESOURCE.to_string(), resource.clone()));
742    }
743    form
744}
745
746#[doc(hidden)]
747pub fn build_token_exchange_form(options: &TokenExchangeOptions) -> Vec<(String, String)> {
748    use crate::constants::oauth_params::*;
749    let mut form = vec![
750        (
751            GRANT_TYPE.to_string(),
752            GRANT_TYPE_TOKEN_EXCHANGE.to_string(),
753        ),
754        (SUBJECT_TOKEN.to_string(), options.subject_token.clone()),
755        (
756            SUBJECT_TOKEN_TYPE.to_string(),
757            if options.subject_token_type.is_empty() {
758                TOKEN_TYPE_ACCESS_TOKEN.to_string()
759            } else {
760                options.subject_token_type.clone()
761            },
762        ),
763    ];
764
765    if !options.actor_token.is_empty() {
766        form.push((ACTOR_TOKEN.to_string(), options.actor_token.clone()));
767        form.push((
768            ACTOR_TOKEN_TYPE.to_string(),
769            if options.actor_token_type.is_empty() {
770                TOKEN_TYPE_ACCESS_TOKEN.to_string()
771            } else {
772                options.actor_token_type.clone()
773            },
774        ));
775    }
776    if !options.scope.is_empty() {
777        form.push((SCOPE.to_string(), options.scope.clone()));
778    }
779    for resource in &options.resources {
780        form.push((RESOURCE.to_string(), resource.clone()));
781    }
782    for audience in &options.audiences {
783        form.push((AUDIENCE.to_string(), audience.clone()));
784    }
785    form
786}
787
788#[cfg(test)]
789mod tests {
790    use serde_json::json;
791
792    use crate::{AuthplaneError, parse_token_exchange_error};
793
794    use super::{
795        GRANT_TYPE_TOKEN_EXCHANGE, IntrospectionResponse, TOKEN_TYPE_ACCESS_TOKEN,
796        TokenExchangeOptions, TokenResponse, build_client_credentials_form,
797        build_token_exchange_form, parse_token_response,
798    };
799
800    #[test]
801    fn maps_consent_required_to_typed_error() {
802        let body = r#"{
803            "error":"consent_required",
804            "error_description":"Consent needed",
805            "service_id":"drive",
806            "cause":"approval_needed",
807            "consent_url":"https://consent.example.com/start"
808        }"#;
809
810        let error = parse_token_exchange_error(Some(400), body);
811        let AuthplaneError::ConsentRequired(consent) = error else {
812            panic!("expected consent required");
813        };
814
815        assert_eq!(consent.code, "consent_required");
816        assert_eq!(consent.service_id, "drive");
817        assert_eq!(consent.cause_detail, "approval_needed");
818        assert_eq!(
819            consent.consent_url.as_deref(),
820            Some("https://consent.example.com/start")
821        );
822    }
823
824    #[test]
825    fn maps_interaction_required_to_typed_error() {
826        let body = r#"{
827            "error":"interaction_required",
828            "error_description":"User interaction required",
829            "service":"calendar"
830        }"#;
831
832        let error = parse_token_exchange_error(Some(400), body);
833        let AuthplaneError::ConsentRequired(consent) = error else {
834            panic!("expected consent required");
835        };
836
837        assert_eq!(consent.code, "interaction_required");
838        assert_eq!(consent.service_id, "calendar");
839        assert_eq!(consent.cause_detail, "User interaction required");
840        assert_eq!(consent.consent_url, None);
841    }
842
843    #[test]
844    fn maps_invalid_json_to_invalid_response_auth_error() {
845        let error = parse_token_exchange_error(Some(500), "{invalid-json");
846        let AuthplaneError::Auth(auth_error) = error else {
847            panic!("expected auth error");
848        };
849
850        assert_eq!(auth_error.code, "invalid_response");
851        assert_eq!(auth_error.message, "OAuth request failed");
852        assert_eq!(auth_error.status_code, Some(500));
853    }
854
855    #[test]
856    fn maps_non_consent_error_to_auth_error() {
857        let body = r#"{
858            "error":"invalid_target",
859            "error_description":"target missing"
860        }"#;
861        let error = parse_token_exchange_error(Some(400), body);
862        let AuthplaneError::Auth(auth_error) = error else {
863            panic!("expected auth error");
864        };
865
866        assert_eq!(auth_error.code, "invalid_target");
867        assert_eq!(auth_error.message, "target missing");
868        assert!(auth_error.is_invalid_target());
869        assert!(!auth_error.is_access_denied());
870        assert!(!crate::should_open_circuit_for_oauth_error(
871            &auth_error.code
872        ));
873    }
874
875    /// authserver 0.2.0 answers a cross-client exchange whose client is
876    /// not allowlisted on the target Resource with `access_denied` and
877    /// HTTP 403. It is a plain `AuthError` — not `ConsentRequired`, since
878    /// no user interaction can clear it — and must not trip the breaker.
879    #[test]
880    fn maps_access_denied_to_auth_error_outside_the_breaker() {
881        let body = r#"{
882            "error":"access_denied",
883            "error_description":"client is not allowed to exchange for this resource"
884        }"#;
885        let error = parse_token_exchange_error(Some(403), body);
886        let AuthplaneError::Auth(auth_error) = error else {
887            panic!("expected auth error");
888        };
889
890        assert_eq!(auth_error.code, "access_denied");
891        assert_eq!(auth_error.status_code, Some(403));
892        assert!(auth_error.is_access_denied());
893        assert!(!auth_error.is_invalid_target());
894        assert!(!crate::should_open_circuit_for_oauth_error(
895            &auth_error.code
896        ));
897    }
898
899    #[test]
900    fn token_exchange_options_filter_empty_resource_and_audience_values() {
901        let options = TokenExchangeOptions {
902            subject_token: "subject".to_string(),
903            resources: vec!["".to_string(), "https://api.example.com".to_string()],
904            audiences: vec!["".to_string(), "api://billing".to_string()],
905            ..TokenExchangeOptions::default()
906        }
907        .normalized();
908
909        assert_eq!(
910            options.resources,
911            vec!["https://api.example.com".to_string()]
912        );
913        assert_eq!(options.audiences, vec!["api://billing".to_string()]);
914    }
915
916    #[test]
917    fn client_credentials_form_includes_scope_and_resource() {
918        let form = build_client_credentials_form(
919            &["tools/read".to_string()],
920            &["https://api.example.com".to_string()],
921        );
922        assert!(form.contains(&("grant_type".to_string(), "client_credentials".to_string())));
923        assert!(form.contains(&("scope".to_string(), "tools/read".to_string())));
924        assert!(form.contains(&(
925            "resource".to_string(),
926            "https://api.example.com".to_string()
927        )));
928    }
929
930    #[test]
931    fn token_exchange_form_applies_defaults_and_repeated_values() {
932        let form = build_token_exchange_form(&TokenExchangeOptions {
933            subject_token: "subject-token".to_string(),
934            resources: vec![
935                "https://api-one.example.com".to_string(),
936                "https://api-two.example.com".to_string(),
937            ],
938            audiences: vec!["api://inventory".to_string()],
939            ..TokenExchangeOptions::default()
940        });
941
942        assert!(form.contains(&(
943            "grant_type".to_string(),
944            GRANT_TYPE_TOKEN_EXCHANGE.to_string()
945        )));
946        assert!(form.contains(&(
947            "subject_token_type".to_string(),
948            TOKEN_TYPE_ACCESS_TOKEN.to_string()
949        )));
950        assert!(form.contains(&(
951            "resource".to_string(),
952            "https://api-one.example.com".to_string()
953        )));
954        assert!(form.contains(&(
955            "resource".to_string(),
956            "https://api-two.example.com".to_string()
957        )));
958        assert!(form.contains(&("audience".to_string(), "api://inventory".to_string())));
959    }
960
961    #[test]
962    fn parse_token_response_requires_issued_token_type_for_exchange() {
963        let payload = json!({
964            "access_token": "new-token",
965            "token_type": "Bearer"
966        });
967
968        let error = parse_token_response(&payload, true).expect_err("missing issued token type");
969        let AuthplaneError::Auth(auth_error) = error else {
970            panic!("expected auth error");
971        };
972        assert_eq!(auth_error.code, "protocol_error");
973    }
974
975    #[test]
976    fn parse_token_response_preserves_issued_token_type() {
977        let payload = json!({
978            "access_token": "new-token",
979            "token_type": "Bearer",
980            "issued_token_type": TOKEN_TYPE_ACCESS_TOKEN
981        });
982
983        let response = parse_token_response(&payload, true).expect("valid token response");
984        assert_eq!(response.issued_token_type, TOKEN_TYPE_ACCESS_TOKEN);
985    }
986
987    #[test]
988    fn parse_token_response_rejects_unsupported_token_type() {
989        let payload = json!({
990            "access_token": "new-token",
991            "token_type": "mac"
992        });
993        let error = parse_token_response(&payload, false).expect_err("must reject token type");
994        let AuthplaneError::Auth(auth_error) = error else {
995            panic!("expected auth error");
996        };
997        assert_eq!(auth_error.code, "protocol_error");
998        assert!(auth_error.message.contains("unsupported token_type"));
999    }
1000
1001    #[test]
1002    fn parse_token_response_rejects_unsupported_issued_token_type() {
1003        let payload = json!({
1004            "access_token": "new-token",
1005            "token_type": "Bearer",
1006            "issued_token_type": "urn:ietf:params:oauth:token-type:refresh_token"
1007        });
1008        let error =
1009            parse_token_response(&payload, true).expect_err("must reject issued token type");
1010        let AuthplaneError::Auth(auth_error) = error else {
1011            panic!("expected auth error");
1012        };
1013        assert_eq!(auth_error.code, "protocol_error");
1014        assert!(auth_error.message.contains("unsupported issued_token_type"));
1015    }
1016
1017    #[test]
1018    fn parse_token_response_rejects_negative_expires_in() {
1019        let payload = json!({
1020            "access_token": "new-token",
1021            "token_type": "Bearer",
1022            "expires_in": -1
1023        });
1024        let error =
1025            parse_token_response(&payload, false).expect_err("must reject negative expires_in");
1026        let AuthplaneError::Auth(auth_error) = error else {
1027            panic!("expected auth error");
1028        };
1029        assert_eq!(auth_error.code, "protocol_error");
1030        assert!(auth_error.message.contains("must be non-negative"));
1031    }
1032
1033    #[test]
1034    fn parse_token_response_rejects_non_integer_expires_in() {
1035        let payload = json!({
1036            "access_token": "new-token",
1037            "token_type": "Bearer",
1038            "expires_in": "abc"
1039        });
1040        let error =
1041            parse_token_response(&payload, false).expect_err("must reject non-integer expires_in");
1042        let AuthplaneError::Auth(auth_error) = error else {
1043            panic!("expected auth error");
1044        };
1045        assert_eq!(auth_error.code, "protocol_error");
1046        assert!(auth_error.message.contains("must be an integer"));
1047    }
1048
1049    #[test]
1050    fn parse_token_response_missing_expires_in_becomes_none() {
1051        // A missing `expires_in` is a *signal*: the AS did not commit to a
1052        // lifetime hint. The cache layer uses this to apply its configured
1053        // `default_ttl`. Previously this field was defaulted to `0`, which
1054        // collided with the RFC 6749 §5.1 explicit-zero shape and silently
1055        // extended a deliberately-immediate-expiry token to one hour.
1056        let payload = json!({
1057            "access_token": "new-token",
1058            "token_type": "Bearer"
1059        });
1060        let response = parse_token_response(&payload, false).expect("valid response");
1061        assert_eq!(response.expires_in, None);
1062    }
1063
1064    #[test]
1065    fn parse_token_response_explicit_zero_expires_in_becomes_some_zero() {
1066        // RFC 6749 §5.1 permits `expires_in: 0` for one-shot flows.
1067        // Distinguished from a missing hint via `Some(0)` vs `None`.
1068        let payload = json!({
1069            "access_token": "new-token",
1070            "token_type": "Bearer",
1071            "expires_in": 0
1072        });
1073        let response = parse_token_response(&payload, false).expect("valid response");
1074        assert_eq!(response.expires_in, Some(0));
1075    }
1076
1077    #[test]
1078    fn client_credentials_form_emits_one_resource_per_value() {
1079        // RFC 8707 §2 — a client that needs multiple resources MUST emit
1080        // one `resource=` parameter per value, not a joined single string.
1081        let form = build_client_credentials_form(
1082            &["tools/read".to_string()],
1083            &[
1084                "https://api-one.example.com".to_string(),
1085                "https://api-two.example.com".to_string(),
1086            ],
1087        );
1088        let resource_entries: Vec<_> = form.iter().filter(|(k, _)| k == "resource").collect();
1089        assert_eq!(resource_entries.len(), 2);
1090        assert!(
1091            resource_entries
1092                .iter()
1093                .any(|(_, v)| v == "https://api-one.example.com")
1094        );
1095        assert!(
1096            resource_entries
1097                .iter()
1098                .any(|(_, v)| v == "https://api-two.example.com")
1099        );
1100    }
1101
1102    #[test]
1103    fn client_credentials_form_space_joins_multiple_scopes() {
1104        // RFC 6749 §3.3 — multiple scope values MUST be space-delimited
1105        // (URL-encoding is handled by the HTTP client).
1106        let form = build_client_credentials_form(
1107            &["tools/read".to_string(), "tools/write".to_string()],
1108            &[],
1109        );
1110        let scope = form
1111            .iter()
1112            .find(|(k, _)| k == "scope")
1113            .map(|(_, v)| v.clone())
1114            .expect("scope must be present");
1115        assert_eq!(scope, "tools/read tools/write");
1116    }
1117
1118    #[test]
1119    fn client_credentials_form_omits_empty_scope() {
1120        let form = build_client_credentials_form(&[], &[]);
1121        assert!(
1122            !form.iter().any(|(k, _)| k == "scope"),
1123            "scope must be omitted when empty"
1124        );
1125    }
1126
1127    #[test]
1128    fn token_exchange_form_uses_token_exchange_grant_type() {
1129        let options = TokenExchangeOptions {
1130            subject_token: "subject-1".to_string(),
1131            ..TokenExchangeOptions::default()
1132        };
1133        let form = build_token_exchange_form(&options);
1134        assert!(form.contains(&(
1135            "grant_type".to_string(),
1136            GRANT_TYPE_TOKEN_EXCHANGE.to_string()
1137        )));
1138    }
1139
1140    #[test]
1141    fn token_exchange_form_defaults_subject_token_type_when_missing() {
1142        // RFC 8693 §2.1 — when subject_token_type is not set by the
1143        // caller, the SDK MUST default to access-token.
1144        let options = TokenExchangeOptions {
1145            subject_token: "subject-1".to_string(),
1146            ..TokenExchangeOptions::default()
1147        };
1148        let form = build_token_exchange_form(&options);
1149        let subject_type = form
1150            .iter()
1151            .find(|(k, _)| k == "subject_token_type")
1152            .map(|(_, v)| v.clone())
1153            .expect("subject_token_type must be present");
1154        assert_eq!(subject_type, TOKEN_TYPE_ACCESS_TOKEN);
1155    }
1156
1157    #[test]
1158    fn token_exchange_form_defaults_actor_token_type_only_when_actor_token_present() {
1159        // Actor token type default only applies when actor_token is set —
1160        // otherwise we must not emit a spurious actor_token_type.
1161        let with_actor = TokenExchangeOptions {
1162            subject_token: "subject-1".to_string(),
1163            actor_token: "actor-1".to_string(),
1164            ..TokenExchangeOptions::default()
1165        };
1166        let form = build_token_exchange_form(&with_actor);
1167        assert!(
1168            form.iter()
1169                .any(|(k, v)| k == "actor_token_type" && v == TOKEN_TYPE_ACCESS_TOKEN)
1170        );
1171
1172        let without_actor = TokenExchangeOptions {
1173            subject_token: "subject-1".to_string(),
1174            ..TokenExchangeOptions::default()
1175        };
1176        let form = build_token_exchange_form(&without_actor);
1177        assert!(
1178            !form.iter().any(|(k, _)| k == "actor_token_type"),
1179            "actor_token_type must be omitted when actor_token is missing"
1180        );
1181    }
1182
1183    #[test]
1184    fn token_exchange_form_normalizes_empty_resource_and_audience() {
1185        // Empty-string entries in resources/audiences must not reach the wire.
1186        let options = TokenExchangeOptions {
1187            subject_token: "subject-1".to_string(),
1188            resources: vec!["".to_string(), "https://api.example.com".to_string()],
1189            audiences: vec!["api://billing".to_string(), "".to_string()],
1190            ..TokenExchangeOptions::default()
1191        }
1192        .normalized();
1193        let form = build_token_exchange_form(&options);
1194        let resources: Vec<_> = form.iter().filter(|(k, _)| k == "resource").collect();
1195        let audiences: Vec<_> = form.iter().filter(|(k, _)| k == "audience").collect();
1196        assert_eq!(resources.len(), 1);
1197        assert_eq!(audiences.len(), 1);
1198    }
1199
1200    #[test]
1201    fn parse_token_exchange_error_without_status_code_still_maps_to_auth_error() {
1202        let error = parse_token_exchange_error(None, "not json at all");
1203        let AuthplaneError::Auth(auth_error) = error else {
1204            panic!("expected auth error");
1205        };
1206        assert_eq!(auth_error.code, "invalid_response");
1207        assert_eq!(auth_error.status_code, None);
1208    }
1209
1210    #[test]
1211    fn parse_token_exchange_error_401_maps_to_authentication_failure() {
1212        let body = r#"{"error":"invalid_client","error_description":"bad creds"}"#;
1213        let error = parse_token_exchange_error(Some(401), body);
1214        let AuthplaneError::Auth(auth_error) = error else {
1215            panic!("expected auth error");
1216        };
1217        assert_eq!(auth_error.code, "invalid_client");
1218        assert_eq!(auth_error.status_code, Some(401));
1219    }
1220
1221    #[test]
1222    fn introspection_response_deserialize_derives_cnf_jkt_from_cnf_object() {
1223        // RFC 9449 §6.2 places the DPoP thumbprint at `cnf.jkt`.
1224        // Direct `serde_json::from_value` must surface it as
1225        // `cnf_jkt` so the typed shape matches what `introspect_token`
1226        // builds on the construction path. Previous behaviour left
1227        // `cnf_jkt` empty on direct deserialize — a footgun for any
1228        // cache/proxy that round-tripped the struct through JSON.
1229        let payload = json!({
1230            "active": true,
1231            "token_type": "DPoP",
1232            "cnf": {"jkt": "abc"},
1233        });
1234        let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
1235        assert!(response.active);
1236        assert_eq!(response.cnf_jkt, "abc");
1237        assert_eq!(
1238            response.cnf.and_then(|v| v.get("jkt").cloned()),
1239            Some(json!("abc"))
1240        );
1241    }
1242
1243    #[test]
1244    fn introspection_response_deserializes_with_absent_cnf_defaults_to_empty() {
1245        let payload = json!({"active": true, "token_type": "Bearer"});
1246        let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
1247        assert!(response.active);
1248        assert_eq!(response.cnf, None);
1249        assert_eq!(response.cnf_jkt, "");
1250    }
1251
1252    #[test]
1253    fn introspection_response_round_trips_cnf_binding_through_serde() {
1254        // to_value → from_value must preserve both `cnf` and
1255        // `cnf_jkt`. The two were asymmetric before the
1256        // `#[serde(from = "IntrospectionResponseWire")]` fix: a
1257        // caller building the struct via constructor saw `cnf_jkt`
1258        // populated, but the same struct after a JSON round-trip
1259        // through a cache layer had `cnf_jkt = ""` while `cnf` was
1260        // preserved — two shapes for the same wire payload.
1261        let payload = json!({
1262            "active": true,
1263            "token_type": "DPoP",
1264            "cnf": {"jkt": "thumbprint-abc"},
1265        });
1266        let first: IntrospectionResponse = serde_json::from_value(payload).unwrap();
1267        let serialized = serde_json::to_value(&first).unwrap();
1268        let second: IntrospectionResponse = serde_json::from_value(serialized).unwrap();
1269        assert_eq!(first, second);
1270        assert_eq!(second.cnf_jkt, "thumbprint-abc");
1271    }
1272
1273    #[test]
1274    fn introspection_response_deserialize_drops_non_object_cnf() {
1275        // Defensive parity with `extract_cnf_and_jkt`: a malformed AS
1276        // sending `cnf` as a non-object scalar should not pollute the
1277        // typed shape; we drop the `cnf` and leave `cnf_jkt` empty.
1278        let payload = json!({"active": true, "cnf": "not-an-object"});
1279        let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
1280        assert_eq!(response.cnf, None);
1281        assert_eq!(response.cnf_jkt, "");
1282    }
1283
1284    #[test]
1285    fn token_response_deserialize_derives_cnf_jkt_from_cnf_object() {
1286        // Same symmetric-deserialize fix as the introspection peer
1287        // above, applied to RFC 9449 §6.1 token responses.
1288        let payload = json!({
1289            "access_token": "at",
1290            "token_type": "DPoP",
1291            "expires_in": 3600,
1292            "scope": "tools/echo",
1293            "cnf": {"jkt": "thumbprint-token"},
1294        });
1295        let response: TokenResponse = serde_json::from_value(payload).unwrap();
1296        assert_eq!(response.cnf_jkt, "thumbprint-token");
1297        assert_eq!(
1298            response.cnf.and_then(|v| v.get("jkt").cloned()),
1299            Some(json!("thumbprint-token"))
1300        );
1301    }
1302
1303    #[test]
1304    fn token_response_round_trips_cnf_binding_through_serde() {
1305        let payload = json!({
1306            "access_token": "at",
1307            "token_type": "DPoP",
1308            "expires_in": 3600,
1309            "scope": "tools/echo",
1310            "cnf": {"jkt": "thumbprint-token"},
1311        });
1312        let first: TokenResponse = serde_json::from_value(payload).unwrap();
1313        let serialized = serde_json::to_value(&first).unwrap();
1314        let second: TokenResponse = serde_json::from_value(serialized).unwrap();
1315        assert_eq!(first, second);
1316        assert_eq!(second.cnf_jkt, "thumbprint-token");
1317    }
1318
1319    #[test]
1320    fn token_response_deserialize_ignores_wire_cnf_jkt_when_it_disagrees_with_cnf() {
1321        // A poisoned cache/persistence blob that carries both a `cnf.jkt`
1322        // and a mismatched top-level `cnf_jkt` must resolve to the
1323        // `cnf.jkt` thumbprint — the imperative `parse_token_response_inner`
1324        // path through `extract_cnf_and_jkt` always derives from `cnf`,
1325        // and the serde path is required to agree.
1326        let payload = json!({
1327            "access_token": "at",
1328            "token_type": "DPoP",
1329            "expires_in": 3600,
1330            "scope": "tools/echo",
1331            "cnf": {"jkt": "from-cnf"},
1332            "cnf_jkt": "poisoned-top-level",
1333        });
1334        let response: TokenResponse = serde_json::from_value(payload).unwrap();
1335        assert_eq!(response.cnf_jkt, "from-cnf");
1336    }
1337
1338    #[test]
1339    fn introspection_response_deserialize_ignores_wire_cnf_jkt_when_it_disagrees_with_cnf() {
1340        let payload = json!({
1341            "active": true,
1342            "token_type": "DPoP",
1343            "cnf": {"jkt": "from-cnf"},
1344            "cnf_jkt": "poisoned-top-level",
1345        });
1346        let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
1347        assert_eq!(response.cnf_jkt, "from-cnf");
1348    }
1349}