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::control::Grant;
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 DEFAULT_ACCESS_CLAIM: &str = "groups";
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 orcid_claim: String,
pub name: String,
pub access: Option<AccessRule>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessRule {
pub claim: String,
pub user_values: Vec<String>,
pub admin_values: Vec<String>,
}
fn values(list: &str) -> Vec<String> {
list.split(',')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(String::from)
.collect()
}
impl AccessRule {
pub fn new(claim: &str, user_values: &str, admin_values: &str) -> Result<Option<Self>, String> {
let (user_values, admin_values) = (values(user_values), values(admin_values));
if user_values.is_empty() {
if !admin_values.is_empty() {
return Err("--oidc-admin-values needs --oidc-user-values".to_string());
}
return Ok(None);
}
let claim = non_empty(claim, DEFAULT_ACCESS_CLAIM);
if claim.split('.').any(str::is_empty) {
return Err(format!(
"--oidc-access-claim: {claim:?} is not a dotted path"
));
}
Ok(Some(AccessRule {
claim,
user_values,
admin_values,
}))
}
pub fn grant(&self, values: &[String]) -> Grant {
let any = |wanted: &[String]| values.iter().any(|v| wanted.contains(v));
let admin = any(&self.admin_values);
Grant {
user: admin || any(&self.user_values),
admin,
missing: false,
}
}
}
impl OidcConfig {
pub fn new(
issuer: &str,
client_id: &str,
client_secret: Option<String>,
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());
}
Ok(OidcConfig {
issuer,
client_id: client_id.trim().to_string(),
client_secret: client_secret.filter(|secret| !secret.trim().is_empty()),
orcid_claim: non_empty(orcid_claim, DEFAULT_ORCID_CLAIM),
name: non_empty(name, DEFAULT_NAME),
access: None,
})
}
pub fn with_access(self, access: Option<AccessRule>) -> Self {
OidcConfig { access, ..self }
}
}
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 grant: Option<Grant>,
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)?)
}
fn claim_at<'a>(claims: &'a Value, path: &[&str]) -> Option<&'a Value> {
if path.is_empty() {
return Some(claims);
}
let object = claims.as_object()?;
(1..=path.len()).find_map(|n| claim_at(object.get(&path[..n].join("."))?, &path[n..]))
}
fn claim_values(claims: &Value, path: &str) -> Option<Vec<String>> {
let path: Vec<&str> = path.split('.').collect();
Some(match claim_at(claims, &path)? {
Value::String(value) => vec![value.clone()],
Value::Array(values) => values
.iter()
.filter_map(Value::as_str)
.map(String::from)
.collect(),
_ => Vec::new(),
})
}
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 mut orcid = orcid_in(&claims, &cfg.orcid_claim);
let rule = cfg.access.as_ref();
let mut values = rule.and_then(|rule| claim_values(&claims, &rule.claim));
if (orcid.is_none() || (rule.is_some() && values.is_none()))
&& let (Some(url), Some(access_token)) = (
provider.userinfo_endpoint.as_ref(),
text(&tokens, "access_token"),
)
{
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());
}
orcid = orcid.or_else(|| orcid_in(&info, &cfg.orcid_claim));
if let Some(rule) = rule {
values = values.or_else(|| claim_values(&info, &rule.claim));
}
}
let grant = rule.map(|rule| match values {
Some(values) => rule.grant(&values),
None => {
tracing::error!(
claim = rule.claim,
"the access claim is in neither the ID token nor userinfo"
);
Grant {
missing: true,
..Grant::default()
}
}
});
Ok(SignedIn {
orcid,
grant,
id_token: id_token.to_string(),
})
}
#[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", "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| OidcConfig::new(issuer, "c", None, "orcid", "K");
let cfg = new("https://auth.example.org/realms/r/").unwrap();
assert_eq!(cfg.issuer, ISSUER);
for issuer in ["http://auth.example.org", "ftp://auth.example.org", ""] {
assert!(new(issuer).is_err(), "{issuer}");
}
assert!(new("http://127.0.0.1:8080/realms/r").is_ok());
assert!(new("http://[::1]:8080/realms/r").is_ok());
assert!(new("http://localhost/realms/r").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).is_err(), "{remote}");
assert!(!secure_url(remote, "http://127.0.0.1"), "{remote}");
}
let cfg = OidcConfig::new(ISSUER, "c", Some(" ".to_string()), "", " ").unwrap();
assert_eq!(cfg.orcid_claim, "orcid");
assert_eq!(cfg.name, "Keycloak");
assert_eq!(cfg.client_secret, None);
assert!(OidcConfig::new(ISSUER, " ", None, "", "").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_access_settings() {
assert_eq!(AccessRule::new("", "", "").unwrap(), None);
assert_eq!(AccessRule::new("groups", " , ", "").unwrap(), None);
assert!(AccessRule::new("", "", "/defter/admins").is_err());
let rule = AccessRule::new(" ", " /defter, /lab ,", "/defter/admins")
.unwrap()
.unwrap();
assert_eq!(rule.claim, "groups");
assert_eq!(rule.user_values, ["/defter", "/lab"]);
assert_eq!(rule.admin_values, ["/defter/admins"]);
for bad in ["realm_access..roles", ".roles", "roles."] {
assert!(AccessRule::new(bad, "a", "").is_err(), "{bad}");
}
let rule = AccessRule::new("realm_access.roles", "defter", "")
.unwrap()
.unwrap();
assert_eq!(rule.claim, "realm_access.roles");
}
#[test]
fn reads_the_access_claim() {
let claims = json!({
"groups": ["/defter", 7, "/other"],
"group": "/defter",
"realm_access": {"roles": ["offline_access", "defter"]},
"resource_access": {
"front-matter": {"roles": ["defter-admin"]},
"my.app": {"roles": "defter"},
},
"a.b": "dotted",
"empty": [],
"number": 1,
});
let at = |path| claim_values(&claims, path);
assert_eq!(at("groups").unwrap(), ["/defter", "/other"]);
assert_eq!(at("group").unwrap(), ["/defter"]);
assert_eq!(
at("realm_access.roles").unwrap(),
["offline_access", "defter"]
);
assert_eq!(
at("resource_access.front-matter.roles").unwrap(),
["defter-admin"]
);
assert_eq!(at("resource_access.my.app.roles").unwrap(), ["defter"]);
assert_eq!(at("a.b").unwrap(), ["dotted"]);
assert_eq!(at("empty").unwrap(), Vec::<String>::new());
assert_eq!(at("number").unwrap(), Vec::<String>::new());
for missing in [
"roles",
"realm_access.groups",
"groups.x",
"resource_access.other.roles",
] {
assert_eq!(at(missing), None, "{missing}");
}
}
#[test]
fn grants_by_the_values() {
let rule = AccessRule::new("groups", "/defter", "/defter/admins")
.unwrap()
.unwrap();
let grant =
|values: &[&str]| rule.grant(&values.iter().map(|v| v.to_string()).collect::<Vec<_>>());
let (none, user, admin) = (
Grant::default(),
Grant {
user: true,
admin: false,
..Grant::default()
},
Grant {
user: true,
admin: true,
..Grant::default()
},
);
assert_eq!(grant(&[]), none);
assert_eq!(grant(&["/other", "/defter/"]), none);
assert_eq!(grant(&["/Defter"]), none, "values are compared exactly");
assert_eq!(grant(&["/other", "/defter"]), user);
assert_eq!(grant(&["/defter/admins"]), admin);
assert_eq!(grant(&["/defter", "/defter/admins"]), admin);
}
#[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);
}
}