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}