crates/nornir-auth-oidc/src/rp.rs
//! # The OIDC **relying party**: authorization code + PKCE, verified offline
//!
//! The rest of this crate validates a BEARER that somebody else obtained. This
//! module is the other half: a server that LOGS A PERSON IN through their
//! company's IdP (Entra ID, Okta, Google, Keycloak, Zitadel …) with the
//! authorization-code flow, PKCE (S256), `state` and `nonce`.
//!
//! **Pure: no network, no clock, no randomness.** Every input the flow needs
//! from outside — the discovery document, the JWKS, the token endpoint's
//! answer, the current time, the random bytes for `state`/`nonce`/the PKCE
//! verifier — is handed in by the caller, so the server keeps its own HTTP
//! client (gunnar's appliance already links one) and its own clock, and every
//! rule here is testable without a socket. The pieces:
//!
//! * [`Discovery::parse`] — `/.well-known/openid-configuration`, with the
//! issuer held to an EXACT match (OIDC Discovery §4.3) and every endpoint
//! required to be `https` (loopback `http` only when the caller allows it,
//! for an in-process test IdP).
//! * [`Pkce::from_verifier`], [`random_token`], [`authorize_url`] — the
//! redirect to the IdP.
//! * [`token_request_form`], [`basic_auth_header`], [`parse_token_response`]
//! — the code exchange (`client_secret_basic`, RFC 6749 §2.3.1).
//! * [`verify_id_token`] — the ID token, checked as OIDC Core §3.1.3.7 says,
//! with a strict **algorithm allow-list** ([`DEFAULT_ALGORITHMS`]: RS*,
//! PS*, ES256/ES384 — never `none`, never `HS*`), the key's TYPE matched to
//! the algorithm (an `HS256` header over an RSA key is refused twice: by the
//! allow-list and by the key family), `iss` exact, `aud` ∋ client id (and
//! `azp` when there are several), `exp`/`nbf`/`iat` against the caller's
//! clock with a bounded skew, and the `nonce` compared in constant time.
use jsonwebtoken::jwk::{
AlgorithmParameters, EllipticCurve, Jwk, JwkSet, KeyAlgorithm, PublicKeyUse,
};
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use serde::Deserialize;
/// Why the relying party refused, in words a log line can carry. Never holds
/// a token, a code, a secret or a nonce.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RpError {
/// The input is not what the protocol says it must be.
Malformed(String),
/// A configuration value is unusable (an `http` endpoint, a wrong issuer).
Config(String),
/// The token's `kid` is not in the JWKS: the caller refetches the JWKS
/// once (key rotation) and tries again.
UnknownKey(String),
/// The signature, the algorithm or the key does not hold.
Signature(String),
/// A claim does not hold (issuer, audience, time, nonce).
Claim(String),
/// The IdP answered with an OAuth error (`error`, `error_description`).
Idp(String),
}
impl std::fmt::Display for RpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RpError::Malformed(m) => write!(f, "malformed: {m}"),
RpError::Config(m) => write!(f, "configuration: {m}"),
RpError::UnknownKey(m) => write!(f, "unknown signing key: {m}"),
RpError::Signature(m) => write!(f, "signature: {m}"),
RpError::Claim(m) => write!(f, "claim: {m}"),
RpError::Idp(m) => write!(f, "the identity provider refused: {m}"),
}
}
}
impl std::error::Error for RpError {}
/// The ID-token algorithms a relying party accepts unless told otherwise:
/// asymmetric only. `none` does not exist in [`Algorithm`] at all, and the
/// `HS*` family is absent on purpose — an HMAC ID token is keyed with the
/// client secret, and accepting one is how a public key gets used as an HMAC
/// secret (the classic algorithm-confusion forgery).
pub const DEFAULT_ALGORITHMS: &[Algorithm] = &[
Algorithm::RS256,
Algorithm::RS384,
Algorithm::RS512,
Algorithm::PS256,
Algorithm::PS384,
Algorithm::PS512,
Algorithm::ES256,
Algorithm::ES384,
];
/// The most clock skew [`verify_id_token`] tolerates, whatever it is asked:
/// five minutes.
pub const MAX_SKEW_SECS: i64 = 300;
/// Where an issuer's discovery document lives (OIDC Discovery §4).
pub fn discovery_url(issuer: &str) -> String {
format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
)
}
/// Is `url` acceptable as an IdP endpoint: `https`, or `http` on a loopback
/// host when `allow_loopback_http` (an in-process test IdP only).
pub fn endpoint_ok(url: &str, allow_loopback_http: bool) -> bool {
let lower = url.trim().to_ascii_lowercase();
if let Some(rest) = lower.strip_prefix("https://") {
return !rest.is_empty() && !rest.starts_with('/');
}
if allow_loopback_http {
if let Some(rest) = lower.strip_prefix("http://") {
let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
let host = match host.rsplit_once(':') {
Some((h, port)) if port.bytes().all(|b| b.is_ascii_digit()) => h,
_ => host,
};
return matches!(host, "127.0.0.1" | "localhost" | "[::1]");
}
}
false
}
/// The parts of a discovery document the code flow uses.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Discovery {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub jwks_uri: String,
/// Empty when the IdP does not say.
pub id_token_signing_alg_values_supported: Vec<String>,
/// Empty when the IdP does not say (S256 is still sent: RFC 7636 §4.2).
pub code_challenge_methods_supported: Vec<String>,
/// The discovered `userinfo_endpoint` (https), when the IdP names one. The
/// ONLY userinfo URL a bearer check may call (A34-1): never a guessed
/// `{issuer}/userinfo`.
pub userinfo_endpoint: Option<String>,
/// The discovered RFC 7662 `introspection_endpoint` (https), when the IdP
/// names one: what binds an opaque token to an audience.
pub introspection_endpoint: Option<String>,
}
#[derive(Deserialize)]
struct DiscoveryWire {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
jwks_uri: String,
#[serde(default)]
id_token_signing_alg_values_supported: Vec<String>,
#[serde(default)]
code_challenge_methods_supported: Vec<String>,
#[serde(default)]
userinfo_endpoint: Option<String>,
#[serde(default)]
introspection_endpoint: Option<String>,
}
impl Discovery {
/// Parse a discovery document and hold it to `expected_issuer` — EXACTLY
/// (one trailing `/` aside, which IdPs disagree on): a document that names
/// another issuer is somebody else's, and its keys would verify somebody
/// else's tokens.
pub fn parse(
json: &str,
expected_issuer: &str,
allow_loopback_http: bool,
) -> Result<Self, RpError> {
let wire: DiscoveryWire = serde_json::from_str(json).map_err(|e| {
RpError::Malformed(format!("the discovery document does not parse: {e}"))
})?;
if wire.issuer.trim_end_matches('/') != expected_issuer.trim().trim_end_matches('/') {
return Err(RpError::Config(format!(
"the discovery document names issuer {:?}, not the configured {:?}",
wire.issuer, expected_issuer
)));
}
for (what, url) in [
("authorization_endpoint", &wire.authorization_endpoint),
("token_endpoint", &wire.token_endpoint),
("jwks_uri", &wire.jwks_uri),
] {
if !endpoint_ok(url, allow_loopback_http) {
return Err(RpError::Config(format!(
"{what} {url:?} is not an https URL"
)));
}
}
let optional = |what: &str, url: Option<String>| -> Result<Option<String>, RpError> {
match url.map(|u| u.trim().to_owned()).filter(|u| !u.is_empty()) {
Some(u) if !endpoint_ok(&u, allow_loopback_http) => Err(RpError::Config(format!(
"{what} {u:?} is not an https URL"
))),
other => Ok(other),
}
};
let userinfo_endpoint = optional("userinfo_endpoint", wire.userinfo_endpoint)?;
let introspection_endpoint = optional("introspection_endpoint", wire.introspection_endpoint)?;
if !wire.code_challenge_methods_supported.is_empty()
&& !wire
.code_challenge_methods_supported
.iter()
.any(|m| m == "S256")
{
return Err(RpError::Config(
"the identity provider does not support PKCE S256".to_owned(),
));
}
Ok(Discovery {
issuer: wire.issuer,
authorization_endpoint: wire.authorization_endpoint,
token_endpoint: wire.token_endpoint,
jwks_uri: wire.jwks_uri,
id_token_signing_alg_values_supported: wire.id_token_signing_alg_values_supported,
code_challenge_methods_supported: wire.code_challenge_methods_supported,
userinfo_endpoint,
introspection_endpoint,
})
}
}
/// `bytes`, base64url without padding — `state`, `nonce` and the PKCE
/// verifier from the caller's CSPRNG. Thirty-two bytes make 43 characters,
/// the shortest verifier RFC 7636 allows.
pub fn random_token(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
/// A PKCE pair (RFC 7636, S256 only — `plain` is never sent).
#[derive(Clone, PartialEq, Eq)]
pub struct Pkce {
/// Kept by the server until the code is exchanged; never logged.
pub verifier: String,
/// `BASE64URL(SHA256(verifier))`, sent in the authorization request.
pub challenge: String,
}
impl std::fmt::Debug for Pkce {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Pkce")
.field("challenge", &self.challenge)
.finish_non_exhaustive()
}
}
impl Pkce {
/// The S256 challenge for `verifier` (43–128 unreserved characters).
pub fn from_verifier(verifier: &str) -> Result<Self, RpError> {
use sha2::Digest;
let ok = (43..=128).contains(&verifier.len())
&& verifier
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-._~".contains(&b));
if !ok {
return Err(RpError::Malformed(
"a PKCE verifier is 43-128 unreserved characters".to_owned(),
));
}
Ok(Pkce {
verifier: verifier.to_owned(),
challenge: random_token(&sha2::Sha256::digest(verifier.as_bytes())),
})
}
}
/// RFC 3986 percent-encoding of everything but the unreserved set.
pub fn percent_encode(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for b in text.bytes() {
if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
/// `application/x-www-form-urlencoded`, as RFC 6749 §2.3.1 says the client id
/// and secret are encoded before they go into the Basic header.
fn form_encode(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for b in text.bytes() {
match b {
b' ' => out.push('+'),
b if b.is_ascii_alphanumeric() || b"-._*".contains(&b) => out.push(b as char),
b => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// What the redirect to the IdP carries.
#[derive(Debug, Clone)]
pub struct AuthorizeRequest<'a> {
pub authorization_endpoint: &'a str,
pub client_id: &'a str,
pub redirect_uri: &'a str,
/// `openid` is added when absent.
pub scopes: &'a [&'a str],
pub state: &'a str,
pub nonce: &'a str,
pub code_challenge: &'a str,
/// Optional `login_hint` (the address the person typed).
pub login_hint: Option<&'a str>,
}
/// The authorization URL: `response_type=code`, PKCE S256, `state`, `nonce`.
/// A query the endpoint already carries is kept.
pub fn authorize_url(request: &AuthorizeRequest<'_>) -> Result<String, RpError> {
for (what, value) in [
("client_id", request.client_id),
("redirect_uri", request.redirect_uri),
("state", request.state),
("nonce", request.nonce),
("code_challenge", request.code_challenge),
] {
if value.trim().is_empty() {
return Err(RpError::Malformed(format!("{what} is required")));
}
}
let mut scopes: Vec<&str> = request
.scopes
.iter()
.copied()
.filter(|s| !s.trim().is_empty())
.collect();
if !scopes.contains(&"openid") {
scopes.insert(0, "openid");
}
let mut pairs = vec![
("response_type", "code".to_owned()),
("client_id", request.client_id.to_owned()),
("redirect_uri", request.redirect_uri.to_owned()),
("scope", scopes.join(" ")),
("state", request.state.to_owned()),
("nonce", request.nonce.to_owned()),
("code_challenge", request.code_challenge.to_owned()),
("code_challenge_method", "S256".to_owned()),
];
if let Some(hint) = request.login_hint.filter(|h| !h.trim().is_empty()) {
pairs.push(("login_hint", hint.trim().to_owned()));
}
let query = pairs
.iter()
.map(|(k, v)| format!("{k}={}", percent_encode(v)))
.collect::<Vec<_>>()
.join("&");
let sep = if request.authorization_endpoint.contains('?') {
'&'
} else {
'?'
};
Ok(format!("{}{sep}{query}", request.authorization_endpoint))
}
/// The token request's form fields (`grant_type=authorization_code`). The
/// client authenticates with [`basic_auth_header`]; `client_id` is in the
/// form too, which every IdP accepts beside Basic.
pub fn token_request_form(
code: &str,
redirect_uri: &str,
code_verifier: &str,
client_id: &str,
) -> Vec<(String, String)> {
vec![
("grant_type".to_owned(), "authorization_code".to_owned()),
("code".to_owned(), code.to_owned()),
("redirect_uri".to_owned(), redirect_uri.to_owned()),
("code_verifier".to_owned(), code_verifier.to_owned()),
("client_id".to_owned(), client_id.to_owned()),
]
}
/// The form fields as one `application/x-www-form-urlencoded` body.
pub fn form_body(fields: &[(String, String)]) -> String {
fields
.iter()
.map(|(k, v)| format!("{}={}", form_encode(k), form_encode(v)))
.collect::<Vec<_>>()
.join("&")
}
/// `Basic base64(urlencode(id):urlencode(secret))` — `client_secret_basic`.
pub fn basic_auth_header(client_id: &str, client_secret: &str) -> String {
use base64::Engine;
let pair = format!("{}:{}", form_encode(client_id), form_encode(client_secret));
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(pair)
)
}
/// The token endpoint's answer: the ID token is what the login rests on.
#[derive(Clone, PartialEq, Eq)]
pub struct TokenAnswer {
pub id_token: String,
pub token_type: String,
}
impl std::fmt::Debug for TokenAnswer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenAnswer")
.field("token_type", &self.token_type)
.finish_non_exhaustive()
}
}
/// Parse the token endpoint's body: an `id_token`, or the IdP's OAuth error
/// ([`RpError::Idp`], its `error` and `error_description` only).
pub fn parse_token_response(json: &str) -> Result<TokenAnswer, RpError> {
let value: serde_json::Value = serde_json::from_str(json)
.map_err(|e| RpError::Malformed(format!("the token response does not parse: {e}")))?;
if let Some(error) = value.get("error").and_then(|e| e.as_str()) {
let description = value
.get("error_description")
.and_then(|d| d.as_str())
.unwrap_or_default();
let description: String = description.chars().take(200).collect();
return Err(RpError::Idp(if description.is_empty() {
error.to_owned()
} else {
format!("{error}: {description}")
}));
}
let id_token = value
.get("id_token")
.and_then(|t| t.as_str())
.filter(|t| !t.is_empty())
.ok_or_else(|| RpError::Malformed("the token response carries no id_token".to_owned()))?;
let token_type = value
.get("token_type")
.and_then(|t| t.as_str())
.unwrap_or_default()
.to_owned();
Ok(TokenAnswer {
id_token: id_token.to_owned(),
token_type,
})
}
/// What an ID token must say.
#[derive(Debug, Clone)]
pub struct IdTokenExpect<'a> {
/// The configured issuer; `iss` must equal it (one trailing `/` aside).
pub issuer: &'a str,
pub client_id: &'a str,
/// The nonce this login sent.
pub nonce: &'a str,
/// The caller's clock, seconds since the epoch.
pub now_unix: i64,
/// Tolerated clock skew, capped at [`MAX_SKEW_SECS`].
pub skew_secs: i64,
/// The allow-list. Use [`DEFAULT_ALGORITHMS`]; an `HS*` entry is ignored.
pub algorithms: &'a [Algorithm],
}
/// A verified ID token's claims.
#[derive(Debug, Clone, PartialEq)]
pub struct IdTokenClaims {
pub iss: String,
pub sub: String,
pub aud: Vec<String>,
pub exp: i64,
pub iat: i64,
/// The whole claim set, for the claim a deployment maps to a user name.
pub claims: serde_json::Map<String, serde_json::Value>,
}
impl IdTokenClaims {
/// A string claim (`preferred_username`, `email`, `name`, …), trimmed;
/// `None` when absent, empty or not a string.
pub fn string(&self, name: &str) -> Option<String> {
self.claims
.get(name)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
}
fn is_hmac(alg: Algorithm) -> bool {
matches!(alg, Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512)
}
/// Does `jwk` fit `alg`: its type (RSA for RS*/PS*, P-256 for ES256, P-384 for
/// ES384), its `use` (sig, when stated) and its own `alg` (when stated).
fn key_fits(jwk: &Jwk, alg: Algorithm) -> Result<(), RpError> {
let type_fits = match (&jwk.algorithm, alg) {
(
AlgorithmParameters::RSA(_),
Algorithm::RS256
| Algorithm::RS384
| Algorithm::RS512
| Algorithm::PS256
| Algorithm::PS384
| Algorithm::PS512,
) => true,
(AlgorithmParameters::EllipticCurve(ec), Algorithm::ES256) => {
ec.curve == EllipticCurve::P256
}
(AlgorithmParameters::EllipticCurve(ec), Algorithm::ES384) => {
ec.curve == EllipticCurve::P384
}
_ => false,
};
if !type_fits {
return Err(RpError::Signature(format!(
"the key does not fit algorithm {alg:?}"
)));
}
if let Some(usage) = &jwk.common.public_key_use {
if *usage != PublicKeyUse::Signature {
return Err(RpError::Signature(
"the key is not a signing key".to_owned(),
));
}
}
if let Some(key_alg) = jwk.common.key_algorithm {
let same = matches!(
(key_alg, alg),
(KeyAlgorithm::RS256, Algorithm::RS256)
| (KeyAlgorithm::RS384, Algorithm::RS384)
| (KeyAlgorithm::RS512, Algorithm::RS512)
| (KeyAlgorithm::PS256, Algorithm::PS256)
| (KeyAlgorithm::PS384, Algorithm::PS384)
| (KeyAlgorithm::PS512, Algorithm::PS512)
| (KeyAlgorithm::ES256, Algorithm::ES256)
| (KeyAlgorithm::ES384, Algorithm::ES384)
);
if !same {
return Err(RpError::Signature(format!(
"the key is for {key_alg:?}, the token says {alg:?}"
)));
}
}
Ok(())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn int_claim(
claims: &serde_json::Map<String, serde_json::Value>,
name: &str,
) -> Result<Option<i64>, RpError> {
match claims.get(name) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(v) => v
.as_i64()
.or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f as i64))
.map(Some)
.ok_or_else(|| RpError::Claim(format!("`{name}` is not a number"))),
}
}
/// The signature half every token check shares (ID token and bearer access
/// token, A34-13: one verifier): the size cap, the header's `alg` on the
/// allow-list and never `HS*`, the key by `kid` (a header without one only
/// when the set has exactly one key), the key fitting the algorithm
/// ([`key_fits`]: type, curve, `use`, its own `alg`), and the signature.
/// Returns the claim set, unchecked; the callers hold its claims.
fn verify_signature(
token: &str,
set: &JwkSet,
algorithms: &[Algorithm],
what: &str,
) -> Result<serde_json::Map<String, serde_json::Value>, RpError> {
if token.len() > 16 * 1024 {
return Err(RpError::Malformed(format!("the {what} is longer than 16 KiB")));
}
let header = jsonwebtoken::decode_header(token)
.map_err(|e| RpError::Malformed(format!("the {what}'s header does not parse: {e}")))?;
let alg = header.alg;
if is_hmac(alg) || !algorithms.contains(&alg) {
return Err(RpError::Signature(format!(
"algorithm {alg:?} is not allowed for an {what}"
)));
}
let jwk = match header.kid.as_deref() {
Some(kid) => set.find(kid).ok_or_else(|| {
RpError::UnknownKey(format!("no key {kid:?} in the identity provider's JWKS"))
})?,
None => match set.keys.as_slice() {
[only] => only,
_ => {
return Err(RpError::Signature(format!(
"the {what} names no key and the JWKS holds several"
)))
}
},
};
key_fits(jwk, alg)?;
let key = DecodingKey::from_jwk(jwk)
.map_err(|e| RpError::Signature(format!("the key does not load: {e}")))?;
let mut validation = Validation::new(alg);
validation.algorithms = vec![alg];
// The claims are checked by the callers against the CALLER's clock;
// jsonwebtoken would read the system clock, which a test (and a replay
// check) cannot pin.
validation.validate_exp = false;
validation.validate_nbf = false;
validation.validate_aud = false;
validation.required_spec_claims.clear();
jsonwebtoken::decode::<serde_json::Map<String, serde_json::Value>>(token, &key, &validation)
.map(|data| data.claims)
.map_err(|e| RpError::Signature(format!("the {what} does not verify: {e}")))
}
/// `iss` exact (one trailing `/` aside).
fn check_issuer(
claims: &serde_json::Map<String, serde_json::Value>,
issuer: &str,
what: &str,
) -> Result<String, RpError> {
let iss = claims
.get("iss")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned();
if iss.is_empty() || iss.trim_end_matches('/') != issuer.trim().trim_end_matches('/') {
return Err(RpError::Claim(format!(
"the {what}'s issuer is {iss:?}, not {issuer:?}"
)));
}
Ok(iss)
}
/// `aud` (a string or an array of strings) containing `wanted`.
fn check_audience(
claims: &serde_json::Map<String, serde_json::Value>,
wanted: &str,
what: &str,
) -> Result<Vec<String>, RpError> {
let aud: Vec<String> = match claims.get("aud") {
Some(serde_json::Value::String(one)) => vec![one.clone()],
Some(serde_json::Value::Array(many)) => many
.iter()
.map(|v| {
v.as_str()
.map(str::to_owned)
.ok_or_else(|| RpError::Claim("`aud` holds a non-string".to_owned()))
})
.collect::<Result<_, _>>()?,
_ => return Err(RpError::Claim(format!("the {what} has no audience"))),
};
if wanted.trim().is_empty() || !aud.iter().any(|a| a == wanted) {
return Err(RpError::Claim(format!(
"the {what} is not for this audience (wrong audience)"
)));
}
Ok(aud)
}
/// `exp` in the future, `iat` (required when `iat_required`) and `nbf` not
/// in it, all within the skew. Returns (`exp`, `iat` or 0).
fn check_times(
claims: &serde_json::Map<String, serde_json::Value>,
now: i64,
skew_secs: i64,
iat_required: bool,
what: &str,
) -> Result<(i64, i64), RpError> {
let skew = skew_secs.clamp(0, MAX_SKEW_SECS);
let exp = int_claim(claims, "exp")?
.ok_or_else(|| RpError::Claim(format!("the {what} has no `exp`")))?;
if exp <= now - skew {
return Err(RpError::Claim(format!("the {what} has expired")));
}
let iat = match int_claim(claims, "iat")? {
Some(iat) => iat,
None if iat_required => {
return Err(RpError::Claim(format!("the {what} has no `iat`")))
}
None => 0,
};
if iat > now + skew {
return Err(RpError::Claim(format!("the {what} was issued in the future")));
}
if let Some(nbf) = int_claim(claims, "nbf")? {
if nbf > now + skew {
return Err(RpError::Claim(format!("the {what} is not valid yet")));
}
}
Ok((exp, iat))
}
/// `sub` present and at most 255 bytes.
fn check_subject(
claims: &serde_json::Map<String, serde_json::Value>,
what: &str,
) -> Result<String, RpError> {
let sub = claims
.get("sub")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned();
if sub.is_empty() || sub.len() > 255 {
return Err(RpError::Claim(format!(
"the {what}'s subject is missing or longer than 255"
)));
}
Ok(sub)
}
/// **Verify an ID token** (OIDC Core §3.1.3.7) against `jwks_json`.
///
/// The header's `alg` must be on `expect.algorithms` and never `HS*`; the key
/// is found by `kid` (a header without one is accepted only when the JWKS has
/// exactly one key) and must fit the algorithm; the signature is
/// `jsonwebtoken`'s. Then, against the caller's clock: `iss` exact, `aud`
/// containing the client id, `azp` the client id when present (and required
/// when `aud` has several values), `exp` in the future and `iat`/`nbf` not in
/// it (both within the skew), `sub` present, `nonce` equal.
pub fn verify_id_token(
token: &str,
jwks_json: &str,
expect: &IdTokenExpect<'_>,
) -> Result<IdTokenClaims, RpError> {
const WHAT: &str = "ID token";
if token.len() > 16 * 1024 {
return Err(RpError::Malformed(
"the ID token is longer than 16 KiB".to_owned(),
));
}
let set: JwkSet = serde_json::from_str(jwks_json)
.map_err(|e| RpError::Malformed(format!("the JWKS does not parse: {e}")))?;
let claims = verify_signature(token, &set, expect.algorithms, WHAT)?;
let iss = check_issuer(&claims, expect.issuer, WHAT)?;
let aud = check_audience(&claims, expect.client_id, WHAT).map_err(|e| match e {
RpError::Claim(m) if m.contains("wrong audience") => RpError::Claim(
"the ID token is not for this client (wrong audience)".to_owned(),
),
other => other,
})?;
match claims.get("azp").and_then(|v| v.as_str()) {
Some(azp) if azp != expect.client_id => {
return Err(RpError::Claim(
"the ID token's authorized party is another client".to_owned(),
))
}
None if aud.len() > 1 => {
return Err(RpError::Claim(
"the ID token has several audiences and no authorized party".to_owned(),
))
}
_ => {}
}
let (exp, iat) = check_times(&claims, expect.now_unix, expect.skew_secs, true, WHAT)?;
let sub = check_subject(&claims, WHAT)?;
let nonce = claims
.get("nonce")
.and_then(|v| v.as_str())
.unwrap_or_default();
if expect.nonce.is_empty() || !constant_time_eq(nonce.as_bytes(), expect.nonce.as_bytes()) {
return Err(RpError::Claim(
"the ID token's nonce is not this login's".to_owned(),
));
}
Ok(IdTokenClaims {
iss,
sub,
aud,
exp,
iat,
claims,
})
}
/// What a bearer ACCESS token (a JWT a resource server is handed) must say.
#[derive(Debug, Clone)]
pub struct AccessTokenExpect<'a> {
/// The configured issuer; `iss` must equal it (one trailing `/` aside).
pub issuer: &'a str,
/// This resource server's audience; `aud` must contain it. Required:
/// an empty audience refuses every token.
pub audience: &'a str,
/// The caller's clock, seconds since the epoch.
pub now_unix: i64,
/// Tolerated clock skew, capped at [`MAX_SKEW_SECS`].
pub skew_secs: i64,
/// The allow-list. Use [`DEFAULT_ALGORITHMS`]; an `HS*` entry is ignored.
pub algorithms: &'a [Algorithm],
}
/// **Verify a bearer access token** (a JWT) with the same checks as
/// [`verify_id_token`] (A34-1, A34-3, A34-13, A34-29): the algorithm
/// allow-list, the key fitting the algorithm, the signature, `iss` exact,
/// `aud` containing the configured audience (REQUIRED), `exp` required and in
/// the future, `iat`/`nbf` not in it, `sub` present. There is no nonce and no
/// `azp` rule: an access token's `azp` names the client that asked for it, not
/// this server.
pub fn verify_access_token(
token: &str,
set: &JwkSet,
expect: &AccessTokenExpect<'_>,
) -> Result<IdTokenClaims, RpError> {
const WHAT: &str = "access token";
if expect.audience.trim().is_empty() {
return Err(RpError::Config(
"no audience is configured, so no access token can be bound to this server".to_owned(),
));
}
let claims = verify_signature(token, set, expect.algorithms, WHAT)?;
let iss = check_issuer(&claims, expect.issuer, WHAT)?;
let aud = check_audience(&claims, expect.audience, WHAT)?;
let (exp, iat) = check_times(&claims, expect.now_unix, expect.skew_secs, false, WHAT)?;
let sub = check_subject(&claims, WHAT)?;
Ok(IdTokenClaims {
iss,
sub,
aud,
exp,
iat,
claims,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{jwks_json, sign, TEST_KID};
use jsonwebtoken::{encode, EncodingKey, Header};
use serde_json::json;
const ISS: &str = "https://idp.example.test/realms/acme";
const CLIENT: &str = "gunnar-acme";
const NONCE: &str = "n-0S6_WzA2Mj";
const NOW: i64 = 1_800_000_000;
// A throwaway P-256 key for the ES256 path; test-only, never a credential.
const EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9WExgS1XLDET7dQj\nbF0XxO29mUF1fpz98MGagi99HQuhRANCAARp0GFV7+URFFOg69Wpi2TiAEo4F1uN\nO6zaV3ctsK5HRELg86bwzn8zSz4gN5K7kw/mRb2LJO1cXfMTdT7Ywj8t\n-----END PRIVATE KEY-----\n";
const EC_X: &str = "adBhVe_lERRToOvVqYtk4gBKOBdbjTus2ld3LbCuR0Q";
const EC_Y: &str = "QuDzpvDOfzNLPiA3kruTD-ZFvYsk7Vxd8xN1PtjCPy0";
fn claims() -> serde_json::Value {
json!({
"iss": ISS, "sub": "00u1abcd", "aud": CLIENT, "exp": NOW + 300, "iat": NOW - 5,
"nonce": NONCE, "preferred_username": "bob@acme.example", "email": "bob@acme.example",
})
}
fn expect() -> IdTokenExpect<'static> {
IdTokenExpect {
issuer: ISS,
client_id: CLIENT,
nonce: NONCE,
now_unix: NOW,
skew_secs: 60,
algorithms: DEFAULT_ALGORITHMS,
}
}
fn with(edit: impl FnOnce(&mut serde_json::Map<String, serde_json::Value>)) -> String {
let mut c = claims();
edit(c.as_object_mut().unwrap());
sign(c)
}
#[test]
fn a_good_token_verifies_and_carries_its_claims() {
let got = verify_id_token(&sign(claims()), &jwks_json(), &expect()).expect("verifies");
assert_eq!(got.sub, "00u1abcd");
assert_eq!(
got.string("preferred_username").as_deref(),
Some("bob@acme.example")
);
assert_eq!(got.aud, vec![CLIENT.to_owned()]);
}
#[test]
fn wrong_audience_is_refused() {
let t = with(|c| {
c.insert("aud".into(), json!("somebody-else"));
});
assert!(matches!(
verify_id_token(&t, &jwks_json(), &expect()),
Err(RpError::Claim(_))
));
}
#[test]
fn several_audiences_need_an_azp_naming_this_client() {
let t = with(|c| {
c.insert("aud".into(), json!([CLIENT, "other"]));
});
assert!(matches!(
verify_id_token(&t, &jwks_json(), &expect()),
Err(RpError::Claim(_))
));
let t = with(|c| {
c.insert("aud".into(), json!([CLIENT, "other"]));
c.insert("azp".into(), json!(CLIENT));
});
assert!(verify_id_token(&t, &jwks_json(), &expect()).is_ok());
let t = with(|c| {
c.insert("azp".into(), json!("other"));
});
assert!(matches!(
verify_id_token(&t, &jwks_json(), &expect()),
Err(RpError::Claim(_))
));
}
#[test]
fn wrong_issuer_is_refused() {
let t = with(|c| {
c.insert("iss".into(), json!("https://evil.example.test"));
});
assert!(matches!(
verify_id_token(&t, &jwks_json(), &expect()),
Err(RpError::Claim(_))
));
}
#[test]
fn expiry_and_clock_skew() {
// Expired 30 s ago: inside a 60 s skew, accepted; 120 s ago, refused.
let t = with(|c| {
c.insert("exp".into(), json!(NOW - 30));
});
assert!(verify_id_token(&t, &jwks_json(), &expect()).is_ok());
let t = with(|c| {
c.insert("exp".into(), json!(NOW - 120));
});
assert!(matches!(
verify_id_token(&t, &jwks_json(), &expect()),
Err(RpError::Claim(_))
));
// Issued 10 minutes in the future: refused; a skew asked above the cap is capped.
let t = with(|c| {
c.insert("iat".into(), json!(NOW + 600));
});
let mut e = expect();
e.skew_secs = 10_000;
assert!(matches!(
verify_id_token(&t, &jwks_json(), &e),
Err(RpError::Claim(_))
));
let t = with(|c| {
c.insert("nbf".into(), json!(NOW + 600));
});
assert!(matches!(
verify_id_token(&t, &jwks_json(), &expect()),
Err(RpError::Claim(_))
));
}
#[test]
fn a_wrong_or_missing_nonce_is_refused() {
let t = with(|c| {
c.insert("nonce".into(), json!("another-login"));
});
assert!(matches!(
verify_id_token(&t, &jwks_json(), &expect()),
Err(RpError::Claim(_))
));
let t = with(|c| {
c.remove("nonce");
});
assert!(matches!(
verify_id_token(&t, &jwks_json(), &expect()),
Err(RpError::Claim(_))
));
}
#[test]
fn a_tampered_payload_does_not_verify() {
let t = sign(claims());
let mut parts: Vec<String> = t.split('.').map(str::to_owned).collect();
let forged = with(|c| {
c.insert("sub".into(), json!("admin"));
});
parts[1] = forged.split('.').nth(1).unwrap().to_owned();
let t = parts.join(".");
assert!(matches!(
verify_id_token(&t, &jwks_json(), &expect()),
Err(RpError::Signature(_))
));
}
#[test]
fn alg_none_is_refused() {
use base64::Engine;
let b = |v: &serde_json::Value| {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string())
};
let t = format!(
"{}.{}.",
b(&json!({"alg": "none", "kid": TEST_KID})),
b(&claims())
);
assert!(verify_id_token(&t, &jwks_json(), &expect()).is_err());
}
/// The algorithm-confusion forgery: HS256 keyed with the RSA public key's
/// bytes. Refused by the allow-list, and — even when a careless caller
/// lists HS256 — by the key family.
#[test]
fn hs256_over_an_rsa_key_is_refused_even_when_listed() {
let mut header = Header::new(Algorithm::HS256);
header.kid = Some(TEST_KID.to_owned());
let t = encode(
&header,
&claims(),
&EncodingKey::from_secret(crate::test_support::TEST_N.as_bytes()),
)
.unwrap();
assert!(matches!(
verify_id_token(&t, &jwks_json(), &expect()),
Err(RpError::Signature(_))
));
let mut e = expect();
let careless = [Algorithm::HS256, Algorithm::RS256];
e.algorithms = &careless;
assert!(matches!(
verify_id_token(&t, &jwks_json(), &e),
Err(RpError::Signature(_))
));
}
#[test]
fn an_unknown_kid_says_refetch() {
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("rotated-key".to_owned());
let key = EncodingKey::from_rsa_pem(crate::test_support::TEST_RSA_PEM.as_bytes()).unwrap();
let t = encode(&header, &claims(), &key).unwrap();
assert!(matches!(
verify_id_token(&t, &jwks_json(), &expect()),
Err(RpError::UnknownKey(_))
));
}
#[test]
fn a_key_whose_own_alg_differs_is_refused() {
let jwks = jwks_json().replace("\"alg\":\"RS256\"", "\"alg\":\"PS256\"");
assert!(matches!(
verify_id_token(&sign(claims()), &jwks, &expect()),
Err(RpError::Signature(_))
));
let jwks = jwks_json().replace("\"use\":\"sig\"", "\"use\":\"enc\"");
assert!(matches!(
verify_id_token(&sign(claims()), &jwks, &expect()),
Err(RpError::Signature(_))
));
}
#[test]
fn es256_verifies_and_an_rsa_algorithm_over_an_ec_key_is_refused() {
let jwks = format!(
r#"{{"keys":[{{"kty":"EC","crv":"P-256","kid":"ec1","use":"sig","x":"{EC_X}","y":"{EC_Y}"}}]}}"#
);
let mut header = Header::new(Algorithm::ES256);
header.kid = Some("ec1".to_owned());
let t = encode(
&header,
&claims(),
&EncodingKey::from_ec_pem(EC_PEM.as_bytes()).unwrap(),
)
.unwrap();
assert!(verify_id_token(&t, &jwks, &expect()).is_ok());
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("ec1".to_owned());
let key = EncodingKey::from_rsa_pem(crate::test_support::TEST_RSA_PEM.as_bytes()).unwrap();
let t = encode(&header, &claims(), &key).unwrap();
assert!(matches!(
verify_id_token(&t, &jwks, &expect()),
Err(RpError::Signature(_))
));
}
#[test]
fn discovery_holds_the_issuer_and_https() {
let doc = |iss: &str, base: &str| {
format!(
r#"{{"issuer":"{iss}","authorization_endpoint":"{base}/auth","token_endpoint":"{base}/token","jwks_uri":"{base}/jwks","code_challenge_methods_supported":["S256"]}}"#
)
};
let d = Discovery::parse(&doc(ISS, "https://idp.example.test"), ISS, false).unwrap();
assert_eq!(d.token_endpoint, "https://idp.example.test/token");
assert!(matches!(
Discovery::parse(
&doc("https://other.example.test", "https://idp.example.test"),
ISS,
false
),
Err(RpError::Config(_))
));
assert!(matches!(
Discovery::parse(&doc(ISS, "http://idp.example.test"), ISS, false),
Err(RpError::Config(_))
));
assert!(Discovery::parse(&doc(ISS, "http://127.0.0.1:8080"), ISS, true).is_ok());
assert!(matches!(
Discovery::parse(&doc(ISS, "http://idp.example.test"), ISS, true),
Err(RpError::Config(_))
));
let plain_only = doc(ISS, "https://idp.example.test").replace("[\"S256\"]", "[\"plain\"]");
assert!(matches!(
Discovery::parse(&plain_only, ISS, false),
Err(RpError::Config(_))
));
}
#[test]
fn the_authorize_url_carries_pkce_state_and_nonce() {
// RFC 7636 appendix B.
let pkce = Pkce::from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk").unwrap();
assert_eq!(
pkce.challenge,
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
assert!(Pkce::from_verifier("short").is_err());
let url = authorize_url(&AuthorizeRequest {
authorization_endpoint: "https://idp.example.test/auth?tenant=acme",
client_id: CLIENT,
redirect_uri: "https://gunnar.example/auth/sso/oidc/callback",
scopes: &["email", "profile"],
state: "st",
nonce: NONCE,
code_challenge: &pkce.challenge,
login_hint: Some("bob@acme.example"),
})
.unwrap();
assert!(url.starts_with("https://idp.example.test/auth?tenant=acme&response_type=code&"));
assert!(url.contains("scope=openid%20email%20profile"));
assert!(url.contains("code_challenge_method=S256"));
assert!(url
.contains("redirect_uri=https%3A%2F%2Fgunnar.example%2Fauth%2Fsso%2Foidc%2Fcallback"));
assert!(url.contains("login_hint=bob%40acme.example"));
assert!(
!format!("{pkce:?}").contains(&pkce.verifier),
"the verifier never reaches a Debug line"
);
}
#[test]
fn the_token_exchange_pieces() {
assert_eq!(basic_auth_header("my id", "s3cr%t"), {
use base64::Engine;
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode("my+id:s3cr%25t")
)
});
let body = form_body(&token_request_form(
"c0de",
"https://g.example/cb",
"v",
CLIENT,
));
assert_eq!(body, "grant_type=authorization_code&code=c0de&redirect_uri=https%3A%2F%2Fg.example%2Fcb&code_verifier=v&client_id=gunnar-acme");
let ok = parse_token_response(
r#"{"access_token":"a","token_type":"Bearer","id_token":"x.y.z"}"#,
)
.unwrap();
assert_eq!(ok.id_token, "x.y.z");
assert!(!format!("{ok:?}").contains("x.y.z"));
assert_eq!(
parse_token_response(r#"{"error":"invalid_grant","error_description":"code used"}"#),
Err(RpError::Idp("invalid_grant: code used".to_owned()))
);
assert!(matches!(
parse_token_response(r#"{"access_token":"a"}"#),
Err(RpError::Malformed(_))
));
}
#[test]
fn loopback_http_is_only_loopback() {
assert!(endpoint_ok("https://idp.example.test/x", false));
assert!(!endpoint_ok("http://127.0.0.1:9/x", false));
assert!(endpoint_ok("http://127.0.0.1:9/x", true));
assert!(endpoint_ok("http://localhost/x", true));
assert!(!endpoint_ok("http://127.0.0.1.evil.test/x", true));
assert!(!endpoint_ok("http://evil.test/127.0.0.1", true));
assert!(!endpoint_ok("ftp://127.0.0.1/", true));
}
}