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