#![forbid(unsafe_code)]
#![allow(missing_docs)] #![allow(rustdoc::broken_intra_doc_links)]
#![allow(rustdoc::bare_urls)]
#![allow(rustdoc::redundant_explicit_links)]
#![allow(rustdoc::private_intra_doc_links)]
#![allow(rustdoc::invalid_html_tags)]
use std::collections::HashMap;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum OidcError {
#[error("unknown OIDC issuer: {0}")]
UnknownIssuer(String),
#[error("token validation failed: {0}")]
Validation(String),
#[error("JWKS fetch failed: {0}")]
JwksFetch(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum OidcIssuer {
GitHubActions,
Google,
GitLab,
Okta(String),
AzureAd(String),
Custom { issuer: String, jwks_url: String },
}
impl OidcIssuer {
pub fn issuer_url(&self) -> &str {
match self {
OidcIssuer::GitHubActions => "https://token.actions.githubusercontent.com",
OidcIssuer::Google => "https://accounts.google.com",
OidcIssuer::GitLab => "https://gitlab.com",
OidcIssuer::Okta(tenant) => tenant.as_str(),
OidcIssuer::AzureAd(tenant_id) => tenant_id.as_str(),
OidcIssuer::Custom { issuer, .. } => issuer.as_str(),
}
}
pub fn jwks_url(&self) -> String {
match self {
OidcIssuer::GitHubActions => {
"https://token.actions.githubusercontent.com/.well-known/jwks".to_string()
}
OidcIssuer::Google => "https://www.googleapis.com/oauth2/v3/certs".to_string(),
OidcIssuer::GitLab => "https://gitlab.com/-/jwks".to_string(),
OidcIssuer::Okta(tenant) => format!("{tenant}/oauth2/default/v1/keys"),
OidcIssuer::AzureAd(tenant_id) => {
format!("https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys")
}
OidcIssuer::Custom { jwks_url, .. } => jwks_url.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcClaims {
pub subject: String,
pub issuer: String,
pub audience: Vec<String>,
pub expires_at: i64,
pub issued_at: i64,
pub email: Option<String>,
pub github: Option<GithubClaims>,
pub raw: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GithubClaims {
pub repository: String,
pub workflow: String,
pub ref_: String,
pub sha: String,
pub actor: String,
}
pub struct OidcVerifier {
http: reqwest::blocking::Client,
jwks_cache: std::sync::Mutex<HashMap<String, std::sync::Arc<JwksSet>>>,
}
impl Default for OidcVerifier {
fn default() -> Self {
Self::new()
}
}
impl OidcVerifier {
pub fn new() -> Self {
Self {
http: reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("reqwest client"),
jwks_cache: std::sync::Mutex::new(HashMap::new()),
}
}
pub fn verify(&self, issuer: &OidcIssuer, token: &str) -> Result<OidcClaims, OidcError> {
let expected_iss = issuer.issuer_url();
let jwks = self.fetch_jwks(issuer)?;
let header = jsonwebtoken::decode_header(token)
.map_err(|e| OidcError::Validation(format!("header decode: {e}")))?;
let kid = header
.kid
.ok_or_else(|| OidcError::Validation("token missing kid header".into()))?;
let jwk = jwks
.get(&kid)
.ok_or_else(|| OidcError::Validation(format!("issuer has no key with kid={kid}")))?;
let decoding_key = DecodingKey::from_rsa_components(&jwk.modulus, &jwk.exponent)
.map_err(|e| OidcError::Validation(format!("JWK decode: {e}")))?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[expected_iss]);
validation.validate_aud = false;
let token_data =
decode::<HashMap<String, serde_json::Value>>(token, &decoding_key, &validation)
.map_err(|e| OidcError::Validation(format!("token verify: {e}")))?;
let raw = token_data.claims;
let subject = raw
.get("sub")
.and_then(|v| v.as_str())
.ok_or_else(|| OidcError::Validation("missing sub".into()))?
.to_string();
let email = raw.get("email").and_then(|v| v.as_str()).map(String::from);
let audience: Vec<String> = raw
.get("aud")
.map(|v| match v {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect(),
_ => vec![],
})
.unwrap_or_default();
let expires_at = raw.get("exp").and_then(|v| v.as_i64()).unwrap_or(0);
let issued_at = raw.get("iat").and_then(|v| v.as_i64()).unwrap_or(0);
let github = if raw.contains_key("repository") {
Some(GithubClaims {
repository: raw
.get("repository")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
workflow: raw
.get("workflow")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
ref_: raw
.get("ref")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
sha: raw
.get("sha")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
actor: raw
.get("actor")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
})
} else {
None
};
Ok(OidcClaims {
subject,
issuer: expected_iss.to_string(),
audience,
expires_at,
issued_at,
email,
github,
raw,
})
}
fn fetch_jwks(&self, issuer: &OidcIssuer) -> Result<std::sync::Arc<JwksSet>, OidcError> {
let key = issuer.issuer_url().to_string();
{
let cache = self.jwks_cache.lock().unwrap();
if let Some(jwks) = cache.get(&key) {
return Ok(jwks.clone());
}
}
let url = issuer.jwks_url();
let resp = self
.http
.get(&url)
.send()
.map_err(|e| OidcError::JwksFetch(e.to_string()))?;
let body: serde_json::Value = resp
.json()
.map_err(|e| OidcError::JwksFetch(format!("JWKS parse: {e}")))?;
let mut jwks = JwksSet::default();
if let Some(keys) = body.get("keys").and_then(|v| v.as_array()) {
for k in keys {
let kid = k
.get("kid")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let modulus = k
.get("n")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let exponent = k
.get("e")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if !kid.is_empty() {
jwks.keys.insert(kid, Jwk { modulus, exponent });
}
}
}
let arc = std::sync::Arc::new(jwks);
self.jwks_cache.lock().unwrap().insert(key, arc.clone());
Ok(arc)
}
}
#[derive(Debug, Default)]
struct JwksSet {
keys: HashMap<String, Jwk>,
}
impl JwksSet {
fn get(&self, kid: &str) -> Option<&Jwk> {
self.keys.get(kid)
}
}
#[derive(Debug, Clone)]
struct Jwk {
modulus: String,
exponent: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn github_issuer_has_known_jwks_url() {
assert_eq!(
OidcIssuer::GitHubActions.jwks_url(),
"https://token.actions.githubusercontent.com/.well-known/jwks"
);
}
#[test]
fn google_issuer_uses_googleapis_certs_endpoint() {
assert_eq!(
OidcIssuer::Google.jwks_url(),
"https://www.googleapis.com/oauth2/v3/certs"
);
}
#[test]
fn okta_issuer_includes_tenant_in_url() {
let i = OidcIssuer::Okta("https://example.okta.com".into());
assert_eq!(
i.jwks_url(),
"https://example.okta.com/oauth2/default/v1/keys"
);
}
#[test]
fn custom_issuer_passes_through() {
let i = OidcIssuer::Custom {
issuer: "https://custom.example.com".into(),
jwks_url: "https://custom.example.com/jwks".into(),
};
assert_eq!(i.issuer_url(), "https://custom.example.com");
assert_eq!(i.jwks_url(), "https://custom.example.com/jwks");
}
#[test]
fn verifier_rejects_garbage_token() {
let v = OidcVerifier::new();
let result = v.verify(&OidcIssuer::Google, "not a token");
assert!(result.is_err());
}
}