use std::collections::HashMap;
use std::sync::RwLock;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct OidcConfig {
pub issuer: String,
pub audience: Option<String>,
pub scope_claim: String,
}
impl OidcConfig {
pub fn new(issuer: impl Into<String>) -> Self {
Self {
issuer: issuer.into(),
audience: None,
scope_claim: "scope".to_string(),
}
}
}
#[derive(Default)]
struct Keys {
by_kid: HashMap<String, DecodingKey>,
sole: Option<DecodingKey>,
}
impl Keys {
fn new(by_kid: HashMap<String, DecodingKey>) -> Self {
let sole = if by_kid.len() == 1 {
by_kid.values().next().cloned()
} else {
None
};
Self { by_kid, sole }
}
}
pub struct OidcVerifier {
keys: RwLock<Keys>,
validation: Validation,
scope_claim: String,
refresh: Option<(reqwest::Client, OidcConfig)>,
}
impl OidcVerifier {
pub fn new(
keys: HashMap<String, DecodingKey>,
validation: Validation,
scope_claim: impl Into<String>,
) -> Self {
Self {
keys: RwLock::new(Keys::new(keys)),
validation,
scope_claim: scope_claim.into(),
refresh: None,
}
}
pub fn verify(&self, token: &str) -> Option<Vec<String>> {
let header = decode_header(token).ok()?;
let keys = self.keys.read().ok()?;
let key = match header.kid.as_deref() {
Some(kid) => keys.by_kid.get(kid)?,
None => keys.sole.as_ref()?,
};
let data = decode::<serde_json::Value>(token, key, &self.validation).ok()?;
let scopes = data
.claims
.get(&self.scope_claim)
.map(claim_to_scopes)
.unwrap_or_default();
(!scopes.is_empty()).then_some(scopes)
}
pub async fn refresh(&self) -> Result<(), OidcError> {
let Some((http, config)) = &self.refresh else {
return Ok(());
};
let by_kid = fetch_jwks_keys(http, config).await?;
*self.keys.write().map_err(|_| OidcError::NoKeys)? = Keys::new(by_kid);
Ok(())
}
pub async fn from_discovery(
http: &reqwest::Client,
config: &OidcConfig,
) -> Result<Self, OidcError> {
let by_kid = fetch_jwks_keys(http, config).await?;
Ok(Self {
keys: RwLock::new(Keys::new(by_kid)),
validation: config.validation(),
scope_claim: config.scope_claim.clone(),
refresh: Some((http.clone(), config.clone())),
})
}
}
async fn fetch_jwks_keys(
http: &reqwest::Client,
config: &OidcConfig,
) -> Result<HashMap<String, DecodingKey>, OidcError> {
let discovery_url = format!(
"{}/.well-known/openid-configuration",
config.issuer.trim_end_matches('/')
);
let discovery: Discovery = http
.get(&discovery_url)
.send()
.await
.map_err(|e| OidcError::Fetch(e.to_string()))?
.error_for_status()
.map_err(|e| OidcError::Fetch(e.to_string()))?
.json()
.await
.map_err(|e| OidcError::Parse(e.to_string()))?;
let jwks: JwkSet = http
.get(&discovery.jwks_uri)
.send()
.await
.map_err(|e| OidcError::Fetch(e.to_string()))?
.error_for_status()
.map_err(|e| OidcError::Fetch(e.to_string()))?
.json()
.await
.map_err(|e| OidcError::Parse(e.to_string()))?;
let keys = jwks.decoding_keys()?;
if keys.is_empty() {
return Err(OidcError::NoKeys);
}
Ok(keys)
}
impl OidcConfig {
fn validation(&self) -> Validation {
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[&self.issuer]);
match &self.audience {
Some(aud) => validation.set_audience(&[aud]),
None => validation.validate_aud = false,
}
validation
}
}
fn claim_to_scopes(value: &serde_json::Value) -> Vec<String> {
match value {
serde_json::Value::String(s) => s.split_whitespace().map(String::from).collect(),
serde_json::Value::Array(items) => items
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
_ => Vec::new(),
}
}
#[derive(Debug, Deserialize)]
struct Discovery {
jwks_uri: String,
}
#[derive(Debug, Deserialize)]
struct JwkSet {
keys: Vec<Jwk>,
}
#[derive(Debug, Deserialize)]
struct Jwk {
kty: String,
#[serde(default)]
kid: Option<String>,
n: Option<String>,
e: Option<String>,
}
impl JwkSet {
fn decoding_keys(&self) -> Result<HashMap<String, DecodingKey>, OidcError> {
let mut out = HashMap::new();
for jwk in &self.keys {
if jwk.kty != "RSA" {
continue;
}
let (Some(n), Some(e)) = (&jwk.n, &jwk.e) else {
continue;
};
let key = DecodingKey::from_rsa_components(n, e)
.map_err(|err| OidcError::Key(err.to_string()))?;
out.insert(jwk.kid.clone().unwrap_or_default(), key);
}
Ok(out)
}
}
#[derive(Debug, thiserror::Error)]
pub enum OidcError {
#[error("oidc fetch failed: {0}")]
Fetch(String),
#[error("oidc parse failed: {0}")]
Parse(String),
#[error("oidc key error: {0}")]
Key(String),
#[error("oidc issuer exposed no usable signing keys")]
NoKeys,
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{encode, EncodingKey, Header};
fn hs256_pair(issuer: &str, audience: Option<&str>) -> (OidcVerifier, EncodingKey, Header) {
let secret = b"test-signing-secret-0123456789";
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&[issuer]);
match audience {
Some(aud) => validation.set_audience(&[aud]),
None => validation.validate_aud = false,
}
let mut keys = HashMap::new();
keys.insert("test-kid".to_string(), DecodingKey::from_secret(secret));
let verifier = OidcVerifier::new(keys, validation, "scope");
let mut header = Header::new(Algorithm::HS256);
header.kid = Some("test-kid".to_string());
(verifier, EncodingKey::from_secret(secret), header)
}
fn sign(key: &EncodingKey, header: &Header, claims: serde_json::Value) -> String {
encode(header, &claims, key).unwrap()
}
fn exp() -> i64 {
4_102_444_800 }
#[test]
fn maps_space_delimited_scope_claim() {
let (v, key, header) = hs256_pair("https://issuer.test", None);
let token = sign(
&key,
&header,
serde_json::json!({"iss": "https://issuer.test", "exp": exp(), "scope": "site:blog site:docs"}),
);
assert_eq!(
v.verify(&token),
Some(vec!["site:blog".to_string(), "site:docs".to_string()])
);
}
#[test]
fn maps_array_scope_claim() {
let (v, key, header) = hs256_pair("https://issuer.test", None);
let token = sign(
&key,
&header,
serde_json::json!({"iss": "https://issuer.test", "exp": exp(), "scope": ["*"]}),
);
assert_eq!(v.verify(&token), Some(vec!["*".to_string()]));
}
#[test]
fn rejects_wrong_issuer() {
let (v, key, header) = hs256_pair("https://issuer.test", None);
let token = sign(
&key,
&header,
serde_json::json!({"iss": "https://evil.test", "exp": exp(), "scope": "*"}),
);
assert_eq!(v.verify(&token), None);
}
#[test]
fn rejects_wrong_audience() {
let (v, key, header) = hs256_pair("https://issuer.test", Some("boatramp-api"));
let token = sign(
&key,
&header,
serde_json::json!({"iss": "https://issuer.test", "aud": "other", "exp": exp(), "scope": "*"}),
);
assert_eq!(v.verify(&token), None);
}
#[test]
fn rejects_expired_token() {
let (v, key, header) = hs256_pair("https://issuer.test", None);
let token = sign(
&key,
&header,
serde_json::json!({"iss": "https://issuer.test", "exp": 1_000_000_000, "scope": "*"}),
);
assert_eq!(v.verify(&token), None);
}
#[test]
fn rejects_valid_token_with_no_scope() {
let (v, key, header) = hs256_pair("https://issuer.test", None);
let token = sign(
&key,
&header,
serde_json::json!({"iss": "https://issuer.test", "exp": exp()}),
);
assert_eq!(v.verify(&token), None, "no scope claim → not authenticated");
}
#[test]
fn rejects_tampered_signature() {
let (v, key, header) = hs256_pair("https://issuer.test", None);
let token = sign(
&key,
&header,
serde_json::json!({"iss": "https://issuer.test", "exp": exp(), "scope": "*"}),
);
let tampered = format!("{token}x");
assert_eq!(v.verify(&tampered), None);
}
#[test]
fn parses_rsa_jwks_into_keys() {
let jwks: JwkSet = serde_json::from_value(serde_json::json!({
"keys": [
{
"kty": "RSA",
"kid": "r1",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
"e": "AQAB"
},
{ "kty": "oct", "kid": "skip" }
]
}))
.unwrap();
let keys = jwks.decoding_keys().unwrap();
assert_eq!(keys.len(), 1, "only the RSA key is usable");
assert!(keys.contains_key("r1"));
}
}