Skip to main content

ppoppo_token/id_token/
logout_hint.rs

1//! OIDC RP-Initiated Logout 1.0 §2 — `id_token_hint` validation.
2//!
3//! A logout hint is an id_token the RP previously received, replayed at the
4//! OP's `end_session_endpoint` to identify the End-User's session. This is a
5//! sibling of [`super::verify`] but a *distinct* entry-point because the two
6//! deliberately diverge:
7//!
8//! * **`exp` is ignored.** Access/id tokens are short-lived (≈1h), so a
9//!   logged-in user's most recent id_token is routinely expired by logout
10//!   time. OIDC RP-Initiated Logout §2 treats the id_token as a *hint*;
11//!   refusing an expired one would make logout fail exactly when needed.
12//! * **`nonce` is ignored.** The RP-minted nonce (M66) is an auth-request
13//!   artifact not available at logout. [`super::verify`] *requires*
14//!   `expected_nonce`, so it cannot serve this path.
15//!
16//! What it DOES enforce — the security floor: the EdDSA signature (the token
17//! was issued by THIS OP), `iss`, the id_token profile marker (`cat="id"`,
18//! so an access token cannot be replayed as a logout hint), and presence of
19//! `sub`. `aud` is *returned, not pinned* — the signature already proves the
20//! OP issued the token for that audience, and the OP uses `aud` to select
21//! which RP's `post_logout_redirect_uri` allowlist applies.
22//!
23//! Keeping this inside the engine preserves the M51/M52 invariant: every
24//! path that verifies an id_token's JWS goes through the engine, never
25//! through a consumer-side decode.
26
27use super::AuthError;
28use crate::algorithm::Algorithm;
29use crate::engine::shared_config::SharedVerifyConfig;
30use crate::engine::{check_algorithm, check_header, check_id_token_cat, raw};
31use crate::{KeySet, SharedAuthError};
32
33/// Subject identity extracted from a validated `id_token_hint`.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct LogoutHint {
36    /// OIDC `sub` — the End-User the RP is logging out (PAS `ppnum_id`).
37    pub sub: String,
38    /// OIDC `aud` — the RP `client_id`(s) the id_token was issued for. The
39    /// OP uses this to select the `post_logout_redirect_uri` allowlist.
40    pub aud: Vec<String>,
41}
42
43/// Errors from `id_token_hint` validation (OIDC RP-Initiated Logout §2).
44#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
45pub enum LogoutHintError {
46    /// `iss` claim absent or not equal to the OP's issuer. A hint signed by
47    /// THIS OP must name THIS OP; mismatch = foreign token or forgery.
48    #[error("id_token_hint: issuer mismatch")]
49    IssuerMismatch,
50
51    /// `sub` claim absent or empty — the hint cannot identify a session.
52    #[error("id_token_hint: subject claim absent")]
53    SubjectMissing,
54
55    /// id_token profile assertion failed (`cat != "id"`) — e.g. an access
56    /// token replayed as a logout hint. Carried from
57    /// [`check_id_token_cat`](crate::engine::check_id_token_cat).
58    #[error(transparent)]
59    Profile(#[from] AuthError),
60
61    /// JOSE wire-format / signature error from the shared engine pipeline
62    /// (oversize, JWE/JSON shape, algorithm whitelist, header, `kid`
63    /// resolution, signature). `exp` leniency is NOT signature leniency.
64    #[error(transparent)]
65    Jose(#[from] SharedAuthError),
66}
67
68/// Verify an `id_token_hint` for RP-Initiated Logout. See the module docs
69/// for the deliberate `exp` / `nonce` exemptions.
70///
71/// `expected_issuer` is the OP's pinned issuer; `key_set` is the OP's active
72/// verifying keys (the same set `/.well-known/jwks.json` advertises).
73pub fn verify_logout_hint(
74    token: &str,
75    key_set: &KeySet,
76    expected_issuer: &str,
77) -> Result<LogoutHint, LogoutHintError> {
78    // Canonical id_token JOSE params, mirroring `VerifyConfig::id_token`:
79    // typ pinned to `JWT`, 8 KB size cap, EdDSA whitelist. The `audience`
80    // field is unused by the JOSE pipeline (`check_algorithm` /
81    // `check_header` read only typ / size / alg / kid), so a sentinel
82    // satisfies the shared constructor — `aud` is extracted, not pinned.
83    let shared = SharedVerifyConfig::new(
84        expected_issuer.to_owned(),
85        String::new(),
86        "JWT",
87        8 * 1024,
88        Algorithm::ALL.to_vec(),
89    );
90
91    // M34 size cap, M31 JWS-JSON reject, M15 JWE reject — mirror `verify`.
92    if token.len() > shared.max_token_size {
93        return Err(SharedAuthError::OversizedToken.into());
94    }
95    if token.starts_with('{') {
96        return Err(SharedAuthError::JwsJsonRejected.into());
97    }
98    if token.split('.').count() == 5 {
99        return Err(SharedAuthError::JwePayload.into());
100    }
101
102    // M01-M16a algorithm + header. `check_header` resolves the `kid`
103    // against `key_set` and verifies the EdDSA signature.
104    check_algorithm::run(token, &shared)?;
105    check_header::run(token, &shared, key_set)?;
106
107    let payload = raw::parse_payload_json(token)?;
108
109    // The hint must be an id_token (`cat="id"`), not an access token.
110    check_id_token_cat::run(&payload)?;
111
112    // `iss` must equal the OP issuer. (exp/nonce intentionally unchecked.)
113    if payload.get("iss").and_then(|v| v.as_str()) != Some(expected_issuer) {
114        return Err(LogoutHintError::IssuerMismatch);
115    }
116
117    let sub = payload
118        .get("sub")
119        .and_then(|v| v.as_str())
120        .filter(|s| !s.is_empty())
121        .ok_or(LogoutHintError::SubjectMissing)?
122        .to_owned();
123
124    let aud = match payload.get("aud") {
125        Some(serde_json::Value::String(s)) => vec![s.clone()],
126        Some(serde_json::Value::Array(a)) => a
127            .iter()
128            .filter_map(|v| v.as_str().map(str::to_owned))
129            .collect(),
130        _ => Vec::new(),
131    };
132
133    Ok(LogoutHint { sub, aud })
134}