diff --git a/src/api/identity.rs b/src/api/identity.rs index d2b7a02e..b6ea68ff 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -52,10 +52,27 @@ pub fn routes() -> Vec { prevalidate, authorize, oidcsignin, - oidcsignin_error + oidcsignin_error, + openid_configuration, + jwks ] } +// Issuer and signing key of our own login tokens, for services that verify +// them (e.g. a key connector). The issuer is the `iss` claim, not a URL. +#[get("/.well-known/openid-configuration")] +fn openid_configuration() -> Json { + Json(json!({ + "issuer": *auth::JWT_LOGIN_ISSUER, + "jwks_uri": format!("{}/identity/.well-known/jwks", CONFIG.domain()), + })) +} + +#[get("/.well-known/jwks")] +fn jwks() -> Json { + Json(auth::login_jwks().clone()) +} + #[post("/connect/token", data = "")] async fn login( data: Form, diff --git a/src/auth.rs b/src/auth.rs index 88a59b4b..7c4cbfbc 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -10,10 +10,12 @@ use std::{ }; use chrono::{DateTime, TimeDelta, Utc}; +use data_encoding::BASE64URL_NOPAD; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind}; use num_traits::FromPrimitive; use openssl::rsa::Rsa; use serde::{de::DeserializeOwned, ser::Serialize}; +use serde_json::json; use rocket::{ outcome::try_outcome, @@ -64,6 +66,7 @@ static JWT_2FA_REMEMBER_ISSUER: LazyLock = LazyLock::new(|| format!("{}| static PRIVATE_RSA_KEY: OnceLock = OnceLock::new(); static PUBLIC_RSA_KEY: OnceLock = OnceLock::new(); +static LOGIN_JWKS: OnceLock = OnceLock::new(); pub async fn initialize_keys() -> Result<(), Error> { use std::io::Error as IoError; @@ -90,6 +93,24 @@ pub async fn initialize_keys() -> Result<(), Error> { }; let pub_key_buffer = priv_key.public_key_to_pem()?; + // Expose the public half as a JWKS on /identity/.well-known/ so token + // consumers (e.g. a key connector) don't need a copy of the PEM. + let n = BASE64URL_NOPAD.encode(&priv_key.n().to_vec()); + let e = BASE64URL_NOPAD.encode(&priv_key.e().to_vec()); + // the kid is the RFC 7638 thumbprint of the key + let thumbprint = json!({"e": e, "kty": "RSA", "n": n}).to_string(); + let kid = BASE64URL_NOPAD.encode(&openssl::sha::sha256(thumbprint.as_bytes())); + let jwks = json!({ + "keys": [{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": n, + "e": e, + }] + }); + let enc = EncodingKey::from_rsa_pem(&priv_key_buffer)?; let dec: DecodingKey = DecodingKey::from_rsa_pem(&pub_key_buffer)?; if PRIVATE_RSA_KEY.set(enc).is_err() { @@ -98,9 +119,16 @@ pub async fn initialize_keys() -> Result<(), Error> { if PUBLIC_RSA_KEY.set(dec).is_err() { err!("PUBLIC_RSA_KEY must only be initialized once") } + if LOGIN_JWKS.set(jwks).is_err() { + err!("LOGIN_JWKS must only be initialized once") + } Ok(()) } +pub fn login_jwks() -> &'static serde_json::Value { + LOGIN_JWKS.wait() +} + pub fn encode_jwt(claims: &T) -> String { match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) { Ok(token) => token,