use std::time::{Duration, SystemTime, UNIX_EPOCH};
use aws_lc_rs::digest;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde_json::Value;
use crate::profile;
pub const LOGIN_TTL: Duration = Duration::from_secs(600);
pub const STATE_COOKIE: &str = "defter_oidc";
const CLOCK_SKEW: u64 = 60;
const DEFAULT_ORCID_CLAIM: &str = "orcid";
const DEFAULT_NAME: &str = "Keycloak";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
fn non_empty(value: &str, default: &str) -> String {
match value.trim() {
"" => default.to_string(),
value => value.to_string(),
}
}
#[derive(Debug, Clone)]
pub struct OidcConfig {
pub issuer: String,
pub client_id: String,
pub client_secret: Option<String>,
pub allowed_orcids: Vec<String>,
pub orcid_claim: String,
pub name: String,
}
impl OidcConfig {
pub fn new(
issuer: &str,
client_id: &str,
client_secret: Option<String>,
allowed: &str,
orcid_claim: &str,
name: &str,
) -> Result<Self, String> {
let issuer = issuer.trim().trim_end_matches('/').to_string();
if !secure_url(&issuer, &issuer) {
return Err(format!("--oidc-issuer: {issuer:?} is not an https URL"));
}
if client_id.trim().is_empty() {
return Err("--oidc-client-id is empty".to_string());
}
let allowed_orcids = allowed
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(|id| {
profile::normalize_orcid(id)
.ok_or_else(|| format!("--oidc-allowed-orcids: {id:?} is not a valid ORCID iD"))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(OidcConfig {
issuer,
client_id: client_id.trim().to_string(),
client_secret: client_secret.filter(|secret| !secret.trim().is_empty()),
allowed_orcids,
orcid_claim: non_empty(orcid_claim, DEFAULT_ORCID_CLAIM),
name: non_empty(name, DEFAULT_NAME),
})
}
}
fn local_http(url: &str) -> bool {
let Some(rest) = url.strip_prefix("http://") else {
return false;
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
if authority.contains('@') {
return false;
}
let host = match authority
.strip_suffix(']')
.or(authority.rsplit_once("]:").map(|(h, _)| h))
{
Some(ipv6) => format!("{ipv6}]"),
None => authority.split(':').next().unwrap_or("").to_string(),
};
matches!(host.as_str(), "127.0.0.1" | "[::1]" | "localhost")
}
fn secure_url(url: &str, issuer: &str) -> bool {
url.starts_with("https://") || (local_http(url) && local_http(issuer))
}
#[derive(Debug, Clone)]
pub struct PendingLogin {
pub nonce: String,
pub verifier: String,
pub login_session: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Provider {
pub authorization_endpoint: String,
token_endpoint: String,
userinfo_endpoint: Option<String>,
end_session_endpoint: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SignedIn {
pub orcid: Option<String>,
pub id_token: String,
}
fn text<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
value.get(field).and_then(Value::as_str)
}
async fn get_json(request: reqwest::RequestBuilder, what: &str) -> Result<Value, String> {
let response = request
.timeout(REQUEST_TIMEOUT)
.send()
.await
.map_err(|e| format!("{what}: {e}"))?;
let status = response.status();
let body = response.text().await.map_err(|e| format!("{what}: {e}"))?;
if !status.is_success() {
let parsed: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
let error = text(&parsed, "error").unwrap_or("no error given");
return Err(format!("{what}: {status}: {error}"));
}
serde_json::from_str(&body).map_err(|e| format!("{what}: not JSON: {e}"))
}
pub async fn discover(http: &reqwest::Client, cfg: &OidcConfig) -> Result<Provider, String> {
let url = format!("{}/.well-known/openid-configuration", cfg.issuer);
let doc = get_json(http.get(&url), "provider configuration").await?;
if text(&doc, "issuer").map(|i| i.trim_end_matches('/')) != Some(cfg.issuer.as_str()) {
return Err(format!("{url} names another issuer"));
}
let endpoint = |field: &str| -> Result<String, String> {
let url = text(&doc, field).ok_or_else(|| format!("no {field}"))?;
if !secure_url(url, &cfg.issuer) {
return Err(format!("{field} is not an https URL"));
}
Ok(url.to_string())
};
Ok(Provider {
authorization_endpoint: endpoint("authorization_endpoint")?,
token_endpoint: endpoint("token_endpoint")?,
userinfo_endpoint: endpoint("userinfo_endpoint").ok(),
end_session_endpoint: endpoint("end_session_endpoint").ok(),
})
}
pub fn challenge(verifier: &str) -> String {
URL_SAFE_NO_PAD.encode(digest::digest(&digest::SHA256, verifier.as_bytes()))
}
pub fn authorization_url(
provider: &Provider,
cfg: &OidcConfig,
redirect_uri: &str,
state: &str,
pending: &PendingLogin,
) -> Result<String, String> {
let query = serde_urlencoded::to_string([
("response_type", "code"),
("client_id", cfg.client_id.as_str()),
("redirect_uri", redirect_uri),
("scope", "openid"),
("state", state),
("nonce", pending.nonce.as_str()),
("code_challenge", challenge(&pending.verifier).as_str()),
("code_challenge_method", "S256"),
])
.map_err(|e| e.to_string())?;
let separator = if provider.authorization_endpoint.contains('?') {
'&'
} else {
'?'
};
Ok(format!(
"{}{separator}{query}",
provider.authorization_endpoint
))
}
pub fn logout_url(
provider: &Provider,
cfg: &OidcConfig,
id_token: &str,
back: &str,
) -> Option<String> {
let endpoint = provider.end_session_endpoint.as_ref()?;
let query = serde_urlencoded::to_string([
("client_id", cfg.client_id.as_str()),
("id_token_hint", id_token),
("post_logout_redirect_uri", back),
])
.ok()?;
let separator = if endpoint.contains('?') { '&' } else { '?' };
Some(format!("{endpoint}{separator}{query}"))
}
fn verify_id_token(
id_token: &str,
cfg: &OidcConfig,
nonce: &str,
now: u64,
) -> Result<Value, String> {
let payload = id_token
.split('.')
.nth(1)
.ok_or("the ID token is not a JWT")?;
let payload = URL_SAFE_NO_PAD
.decode(payload)
.map_err(|e| format!("the ID token is not base64url: {e}"))?;
let claims: Value =
serde_json::from_slice(&payload).map_err(|e| format!("the ID token is not JSON: {e}"))?;
if text(&claims, "iss").map(|i| i.trim_end_matches('/')) != Some(cfg.issuer.as_str()) {
return Err("the ID token is from another issuer".to_string());
}
let client = cfg.client_id.as_str();
let for_this_client = match claims.get("aud") {
Some(Value::String(aud)) => aud == client,
Some(Value::Array(auds)) => auds.iter().any(|a| a.as_str() == Some(client)),
_ => false,
};
if !for_this_client {
return Err("the ID token is for another client".to_string());
}
if text(&claims, "azp").is_some_and(|azp| azp != client) {
return Err("the ID token was issued to another client".to_string());
}
let expiry = claims.get("exp").and_then(Value::as_u64).unwrap_or(0);
if expiry.saturating_add(CLOCK_SKEW) <= now {
return Err("the ID token has expired".to_string());
}
if text(&claims, "nonce") != Some(nonce) {
return Err("the ID token is from another sign-in".to_string());
}
if text(&claims, "sub").is_none_or(str::is_empty) {
return Err("the ID token names no subject".to_string());
}
Ok(claims)
}
fn orcid_in(claims: &Value, claim: &str) -> Option<String> {
profile::normalize_orcid(text(claims, claim)?)
}
pub async fn signed_in_orcid(
http: &reqwest::Client,
cfg: &OidcConfig,
provider: &Provider,
redirect_uri: &str,
code: &str,
pending: &PendingLogin,
) -> Result<SignedIn, String> {
let mut form = vec![
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("client_id", cfg.client_id.as_str()),
("code_verifier", pending.verifier.as_str()),
];
if let Some(secret) = &cfg.client_secret {
form.push(("client_secret", secret.as_str()));
}
let body = serde_urlencoded::to_string(&form).map_err(|e| e.to_string())?;
let request = http
.post(&provider.token_endpoint)
.header("content-type", "application/x-www-form-urlencoded")
.body(body);
let tokens = get_json(request, "token exchange").await?;
let id_token = text(&tokens, "id_token").ok_or("no ID token in the token response")?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let claims = verify_id_token(id_token, cfg, &pending.nonce, now)?;
let signed_in = |orcid| SignedIn {
orcid,
id_token: id_token.to_string(),
};
if let Some(orcid) = orcid_in(&claims, &cfg.orcid_claim) {
return Ok(signed_in(Some(orcid)));
}
let (Some(url), Some(access_token)) = (
provider.userinfo_endpoint.as_ref(),
text(&tokens, "access_token"),
) else {
return Ok(signed_in(None));
};
let info = get_json(http.get(url).bearer_auth(access_token), "userinfo").await?;
if text(&info, "sub") != text(&claims, "sub") {
return Err("userinfo is about another account".to_string());
}
Ok(signed_in(orcid_in(&info, &cfg.orcid_claim)))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const ORCID: &str = "0000-0003-1419-2405";
const ISSUER: &str = "https://auth.example.org/realms/r";
fn config() -> OidcConfig {
OidcConfig::new(ISSUER, "defter", None, ORCID, "orcid", "Keycloak").unwrap()
}
fn token(claims: Value) -> String {
let part = |v: &Value| URL_SAFE_NO_PAD.encode(v.to_string());
format!("{}.{}.sig", part(&json!({"alg": "RS256"})), part(&claims))
}
fn good() -> Value {
json!({"iss": ISSUER, "aud": "defter", "azp": "defter", "exp": 2000, "nonce": "n",
"sub": "abc", "orcid": ORCID})
}
#[test]
fn checks_the_settings() {
let new = |issuer, allowed| OidcConfig::new(issuer, "c", None, allowed, "orcid", "K");
let cfg = new(
"https://auth.example.org/realms/r/",
"https://orcid.org/0000-0003-1419-2405, ",
)
.unwrap();
assert_eq!(cfg.issuer, ISSUER);
assert_eq!(cfg.allowed_orcids, [ORCID]);
assert!(new(ISSUER, " , ").unwrap().allowed_orcids.is_empty());
for (issuer, allowed) in [
("http://auth.example.org", ORCID),
("ftp://auth.example.org", ORCID),
("", ORCID),
(ISSUER, "0000-0003-1419-2404"),
(ISSUER, "someone@example.org"),
] {
assert!(new(issuer, allowed).is_err(), "{issuer} {allowed}");
}
assert!(new("http://127.0.0.1:8080/realms/r", ORCID).is_ok());
assert!(new("http://[::1]:8080/realms/r", ORCID).is_ok());
assert!(new("http://localhost/realms/r", ORCID).is_ok());
assert!(!secure_url("http://127.0.0.1/token", ISSUER));
for remote in [
"http://localhost.example.org/realms/r",
"http://127.0.0.1.nip.io/realms/r",
"http://localhost@example.org/realms/r",
"http://127.0.0.1:80@example.org/realms/r",
"http://[::1].example.org/realms/r",
] {
assert!(new(remote, ORCID).is_err(), "{remote}");
assert!(!secure_url(remote, "http://127.0.0.1"), "{remote}");
}
let cfg = OidcConfig::new(ISSUER, "c", Some(" ".to_string()), ORCID, "", " ").unwrap();
assert_eq!(cfg.orcid_claim, "orcid");
assert_eq!(cfg.name, "Keycloak");
assert_eq!(cfg.client_secret, None);
assert!(OidcConfig::new(ISSUER, " ", None, ORCID, "", "").is_err());
}
#[test]
fn computes_the_pkce_challenge() {
assert_eq!(
challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
}
#[test]
fn verifies_the_id_token() {
let check = |claims: Value| verify_id_token(&token(claims), &config(), "n", 1000);
assert_eq!(check(good()).unwrap()["sub"], "abc");
let mut list = good();
list["aud"] = json!(["other", "defter"]);
assert!(check(list).is_ok());
let mut slash = good();
slash["iss"] = json!(format!("{ISSUER}/"));
assert!(check(slash).is_ok());
let mut skew = good();
skew["exp"] = json!(1000 - CLOCK_SKEW + 1);
assert!(check(skew).is_ok());
for (field, bad) in [
("iss", json!("https://evil.example/realms/r")),
("aud", json!("other")),
("aud", json!(["other"])),
("azp", json!("other")),
("exp", json!(1000 - CLOCK_SKEW)),
("nonce", json!("other")),
("sub", json!("")),
] {
let mut claims = good();
claims[field] = bad.clone();
assert!(check(claims).is_err(), "{field}: {bad}");
}
for missing in ["iss", "aud", "exp", "nonce", "sub"] {
let mut claims = good();
claims.as_object_mut().unwrap().remove(missing);
assert!(check(claims).is_err(), "no {missing}");
}
for broken in ["", "nodots", "a.%%%.c", "a.bm90anNvbg.c"] {
assert!(
verify_id_token(broken, &config(), "n", 1000).is_err(),
"{broken}"
);
}
}
#[test]
fn reads_the_orcid_claim() {
let orcid = |v: Value| orcid_in(&json!({"orcid": v}), "orcid");
assert_eq!(orcid(json!(ORCID)).as_deref(), Some(ORCID));
assert_eq!(
orcid(json!("https://orcid.org/0000-0003-1419-2405")).as_deref(),
Some(ORCID)
);
assert_eq!(orcid(json!("0000-0003-1419-2404")), None);
assert_eq!(orcid(json!(1)), None);
assert_eq!(orcid_in(&json!({}), "orcid"), None);
}
}