1use std::path::Path;
10
11use axum::routing::get;
12use axum::{Json, Router};
13use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
14use base64::Engine;
15use serde_json::{json, Map, Value};
16use sha2::{Digest, Sha256};
17
18use crate::config::OidcDiscoveryConfig;
19
20const ED25519_PUBLIC_KEY_LEN: usize = 32;
22
23const ED25519_SPKI_PREFIX: [u8; 12] = [
26 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
27];
28
29pub struct Oidc {
31 discovery: Value,
32 jwks: Value,
33 jwks_path: String,
34}
35
36impl Oidc {
37 pub fn build(config: &OidcDiscoveryConfig) -> Result<Option<Self>, String> {
43 if !config.enabled {
44 return Ok(None);
45 }
46
47 let alg = config
48 .signing_key
49 .as_ref()
50 .map(|k| k.algorithm.clone())
51 .unwrap_or_else(|| "EdDSA".to_string());
52 let jwks_uri = config.jwks_uri.clone().unwrap_or_else(|| {
53 format!(
54 "{}/.well-known/jwks.json",
55 config.issuer.trim_end_matches('/')
56 )
57 });
58
59 let discovery = discovery_document(config, &alg, &jwks_uri);
60
61 let jwks = match &config.signing_key {
64 Some(sk) => json!({ "keys": [ed25519_jwk(&sk.public_key_pem_file, &alg)?] }),
65 None => json!({ "keys": [] }),
66 };
67
68 Ok(Some(Self {
69 discovery,
70 jwks,
71 jwks_path: jwks_uri_path(&jwks_uri),
72 }))
73 }
74
75 pub fn routes<S>(&self) -> Router<S>
77 where
78 S: Clone + Send + Sync + 'static,
79 {
80 let discovery = self.discovery.clone();
81 let jwks_body =
83 serde_json::to_string(&self.jwks).unwrap_or_else(|_| "{\"keys\":[]}".to_string());
84 Router::new()
85 .route(
86 "/.well-known/openid-configuration",
87 get(move || {
88 let doc = discovery.clone();
89 async move { Json(doc) }
90 }),
91 )
92 .route(
93 &self.jwks_path,
94 get(move || {
95 let body = jwks_body.clone();
96 async move {
97 (
98 [(axum::http::header::CONTENT_TYPE, "application/jwk-set+json")],
99 body,
100 )
101 }
102 }),
103 )
104 }
105}
106
107fn discovery_document(config: &OidcDiscoveryConfig, alg: &str, jwks_uri: &str) -> Value {
109 let mut m = Map::new();
110 m.insert("issuer".into(), json!(config.issuer));
111 if let Some(v) = &config.authorization_endpoint {
112 m.insert("authorization_endpoint".into(), json!(v));
113 }
114 if let Some(v) = &config.token_endpoint {
115 m.insert("token_endpoint".into(), json!(v));
116 }
117 if let Some(v) = &config.userinfo_endpoint {
118 m.insert("userinfo_endpoint".into(), json!(v));
119 }
120 m.insert("jwks_uri".into(), json!(jwks_uri));
121 m.insert(
122 "response_types_supported".into(),
123 json!(["code", "id_token", "token id_token"]),
124 );
125 m.insert("subject_types_supported".into(), json!(["public"]));
126 m.insert("id_token_signing_alg_values_supported".into(), json!([alg]));
127 m.insert(
128 "scopes_supported".into(),
129 json!(["openid", "profile", "email"]),
130 );
131 m.insert(
132 "token_endpoint_auth_methods_supported".into(),
133 json!(["client_secret_basic", "client_secret_post"]),
134 );
135 Value::Object(m)
136}
137
138fn jwks_uri_path(uri: &str) -> String {
140 if let Some(rest) = uri.split_once("://") {
142 match rest.1.find('/') {
143 Some(idx) => rest.1[idx..].to_string(),
144 None => "/.well-known/jwks.json".to_string(),
145 }
146 } else if uri.starts_with('/') {
147 uri.to_string()
148 } else {
149 "/.well-known/jwks.json".to_string()
150 }
151}
152
153fn ed25519_jwk(pem_path: &Path, alg: &str) -> Result<Value, String> {
155 if !matches!(alg, "EdDSA" | "Ed25519") {
156 return Err(format!(
157 "oidc_discovery signing key algorithm {alg:?} is not supported (only EdDSA)"
158 ));
159 }
160 let pem = std::fs::read_to_string(pem_path)
161 .map_err(|e| format!("failed to read oidc signing key {pem_path:?}: {e}"))?;
162 let der = decode_pem_body(&pem)?;
163 if der.len() != ED25519_SPKI_PREFIX.len() + ED25519_PUBLIC_KEY_LEN
167 || der[..ED25519_SPKI_PREFIX.len()] != ED25519_SPKI_PREFIX
168 {
169 return Err(
170 "oidc signing key is not a valid Ed25519 (EdDSA) public key in SPKI form".to_string(),
171 );
172 }
173 let raw = &der[ED25519_SPKI_PREFIX.len()..];
174 Ok(json!({
175 "kty": "OKP",
176 "crv": "Ed25519",
177 "use": "sig",
178 "alg": "EdDSA",
179 "kid": key_id(raw),
180 "x": URL_SAFE_NO_PAD.encode(raw),
181 }))
182}
183
184fn decode_pem_body(pem: &str) -> Result<Vec<u8>, String> {
186 let body: String = pem
187 .lines()
188 .filter(|l| !l.starts_with("-----"))
189 .collect::<Vec<_>>()
190 .join("");
191 STANDARD
192 .decode(body.trim())
193 .map_err(|e| format!("invalid PEM base64: {e}"))
194}
195
196fn key_id(raw: &[u8]) -> String {
198 let digest = Sha256::digest(raw);
199 hex16(&digest)
200}
201
202fn hex16(bytes: &[u8]) -> String {
204 use std::fmt::Write;
205 let mut s = String::with_capacity(16);
206 for b in bytes.iter().take(8) {
207 let _ = write!(s, "{b:02x}");
208 }
209 s
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::config::SigningKeyConfig;
216
217 const TEST_PUB_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
218 MCowBQYDK2VwAyEARCMxEnaM2/dblLuPNgBZpTvSUXO5ir+XQ1nyzJm4CFw=\n\
219 -----END PUBLIC KEY-----\n";
220
221 fn write_pub() -> std::path::PathBuf {
222 use std::sync::atomic::{AtomicU32, Ordering};
223 static N: AtomicU32 = AtomicU32::new(0);
224 let p = std::env::temp_dir().join(format!(
225 "sp_oidc_{}_{}.pem",
226 std::process::id(),
227 N.fetch_add(1, Ordering::Relaxed)
228 ));
229 std::fs::write(&p, TEST_PUB_PEM).unwrap();
230 p
231 }
232
233 #[test]
234 fn discovery_document_includes_configured_endpoints() {
235 let cfg = OidcDiscoveryConfig {
236 enabled: true,
237 issuer: "https://idp.example.com".into(),
238 authorization_endpoint: Some("https://idp.example.com/authorize".into()),
239 token_endpoint: Some("https://idp.example.com/token".into()),
240 userinfo_endpoint: None,
241 jwks_uri: None,
242 signing_key: None,
243 };
244 let doc = discovery_document(
245 &cfg,
246 "EdDSA",
247 "https://idp.example.com/.well-known/jwks.json",
248 );
249 assert_eq!(doc["issuer"], "https://idp.example.com");
250 assert_eq!(
251 doc["authorization_endpoint"],
252 "https://idp.example.com/authorize"
253 );
254 assert_eq!(doc["token_endpoint"], "https://idp.example.com/token");
255 assert!(doc.get("userinfo_endpoint").is_none());
257 assert_eq!(
258 doc["id_token_signing_alg_values_supported"],
259 json!(["EdDSA"])
260 );
261 assert_eq!(
262 doc["jwks_uri"],
263 "https://idp.example.com/.well-known/jwks.json"
264 );
265 }
266
267 #[test]
268 fn jwks_uri_path_extracts_path() {
269 assert_eq!(
270 jwks_uri_path("https://idp.example.com/oauth/keys"),
271 "/oauth/keys"
272 );
273 assert_eq!(jwks_uri_path("/keys.json"), "/keys.json");
274 assert_eq!(
275 jwks_uri_path("https://idp.example.com"),
276 "/.well-known/jwks.json"
277 );
278 }
279
280 #[test]
281 fn ed25519_pem_becomes_okp_jwk() {
282 let path = write_pub();
283 let jwk = ed25519_jwk(&path, "EdDSA").unwrap();
284 assert_eq!(jwk["kty"], "OKP");
285 assert_eq!(jwk["crv"], "Ed25519");
286 assert_eq!(jwk["alg"], "EdDSA");
287 assert_eq!(jwk["x"].as_str().unwrap().len(), 43);
289 assert_eq!(jwk["kid"].as_str().unwrap().len(), 16);
290 }
291
292 #[test]
293 fn non_eddsa_signing_key_is_rejected() {
294 let path = write_pub();
295 assert!(ed25519_jwk(&path, "RS256").is_err());
296 }
297
298 const EC_PUB_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
301 MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgAJ0pjQcIv5a3YQTu2YHKyl9tYB8\n\
302 zxWf7gcS1JeuSRRT6RtezpLXHy5SGMxFCWnJukWOqaLR2lgxTFxQ48HsKA==\n\
303 -----END PUBLIC KEY-----\n";
304
305 #[test]
306 fn non_ed25519_spki_is_rejected_under_eddsa() {
307 use std::sync::atomic::{AtomicU32, Ordering};
308 static N: AtomicU32 = AtomicU32::new(1000);
309 let p = std::env::temp_dir().join(format!(
310 "sp_oidc_ec_{}_{}.pem",
311 std::process::id(),
312 N.fetch_add(1, Ordering::Relaxed)
313 ));
314 std::fs::write(&p, EC_PUB_PEM).unwrap();
315 assert!(ed25519_jwk(&p, "EdDSA").is_err());
318 }
319
320 #[test]
321 fn build_serves_jwks_when_signing_key_present() {
322 let cfg = OidcDiscoveryConfig {
323 enabled: true,
324 issuer: "https://idp.example.com".into(),
325 authorization_endpoint: None,
326 token_endpoint: None,
327 userinfo_endpoint: None,
328 jwks_uri: Some("https://idp.example.com/keys.json".into()),
329 signing_key: Some(SigningKeyConfig {
330 algorithm: "EdDSA".into(),
331 public_key_pem_file: write_pub(),
332 }),
333 };
334 let oidc = Oidc::build(&cfg).unwrap().unwrap();
335 assert_eq!(oidc.jwks_path, "/keys.json");
336 assert_eq!(oidc.jwks["keys"][0]["kty"], "OKP");
337 assert_eq!(
338 oidc.discovery["jwks_uri"],
339 "https://idp.example.com/keys.json"
340 );
341 }
342
343 #[tokio::test]
344 async fn routes_serve_discovery_and_jwks() {
345 use axum::body::Body;
346 use axum::http::Request;
347 use tower::ServiceExt;
348
349 let cfg = OidcDiscoveryConfig {
350 enabled: true,
351 issuer: "https://idp.example.com".into(),
352 authorization_endpoint: None,
353 token_endpoint: None,
354 userinfo_endpoint: None,
355 jwks_uri: Some("https://idp.example.com/keys.json".into()),
356 signing_key: Some(SigningKeyConfig {
357 algorithm: "EdDSA".into(),
358 public_key_pem_file: write_pub(),
359 }),
360 };
361 let app: axum::Router = Oidc::build(&cfg).unwrap().unwrap().routes();
362
363 let disc = app
364 .clone()
365 .oneshot(
366 Request::get("/.well-known/openid-configuration")
367 .body(Body::empty())
368 .unwrap(),
369 )
370 .await
371 .unwrap();
372 assert_eq!(disc.status(), 200);
373
374 let jwks = app
375 .oneshot(Request::get("/keys.json").body(Body::empty()).unwrap())
376 .await
377 .unwrap();
378 assert_eq!(jwks.status(), 200);
379 let body = axum::body::to_bytes(jwks.into_body(), 4096).await.unwrap();
380 let v: Value = serde_json::from_slice(&body).unwrap();
381 assert_eq!(v["keys"][0]["kty"], "OKP");
382 }
383
384 #[tokio::test]
385 async fn jwks_uri_is_served_even_without_signing_key() {
386 use axum::body::Body;
387 use axum::http::Request;
388 use tower::ServiceExt;
389
390 let cfg = OidcDiscoveryConfig {
393 enabled: true,
394 issuer: "https://idp.example.com".into(),
395 authorization_endpoint: None,
396 token_endpoint: None,
397 userinfo_endpoint: None,
398 jwks_uri: None,
399 signing_key: None,
400 };
401 let oidc = Oidc::build(&cfg).unwrap().unwrap();
402 let advertised = oidc.discovery["jwks_uri"].as_str().unwrap().to_string();
403 let path = jwks_uri_path(&advertised);
404 let app: axum::Router = oidc.routes();
405 let resp = app
406 .oneshot(Request::get(&path).body(Body::empty()).unwrap())
407 .await
408 .unwrap();
409 assert_eq!(resp.status(), 200);
410 assert_eq!(resp.headers()["content-type"], "application/jwk-set+json");
411 let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
412 assert_eq!(
413 serde_json::from_slice::<Value>(&body).unwrap(),
414 json!({ "keys": [] })
415 );
416 }
417
418 #[test]
419 fn disabled_yields_none() {
420 let cfg = OidcDiscoveryConfig {
421 enabled: false,
422 issuer: "x".into(),
423 authorization_endpoint: None,
424 token_endpoint: None,
425 userinfo_endpoint: None,
426 jwks_uri: None,
427 signing_key: None,
428 };
429 assert!(Oidc::build(&cfg).unwrap().is_none());
430 }
431}