Skip to main content

structured_proxy/oidc/
mod.rs

1//! OpenID Connect discovery surface.
2//!
3//! When `oidc_discovery.enabled`, the proxy serves the OpenID Provider metadata
4//! document at `/.well-known/openid-configuration` and a JWKS document at the
5//! advertised `jwks_uri` path (built from the configured signing key, or an
6//! empty set when none is set). This lets the proxy front an identity provider
7//! so relying parties can discover endpoints and keys.
8
9use 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
20/// Length of an Ed25519 public key in bytes (the tail of its SPKI encoding).
21const ED25519_PUBLIC_KEY_LEN: usize = 32;
22
23/// Fixed 12-byte SPKI header that precedes the 32-byte key in an Ed25519
24/// `SubjectPublicKeyInfo` (`AlgorithmIdentifier` for `id-Ed25519` + BIT STRING).
25const ED25519_SPKI_PREFIX: [u8; 12] = [
26    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
27];
28
29/// Precomputed OIDC discovery responses.
30pub struct Oidc {
31    discovery: Value,
32    jwks: Value,
33    jwks_path: String,
34}
35
36impl Oidc {
37    /// Build the discovery responses, or `None` when discovery is disabled.
38    ///
39    /// # Errors
40    /// Returns an error when a configured signing key cannot be read or is not a
41    /// supported (Ed25519) public key.
42    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        // Always have a JWKS to serve: an empty set when no signing key is
62        // configured, so the advertised `jwks_uri` never 404s.
63        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    /// The paths this serves (discovery document + JWKS), for collision checks
76    /// against other routes mounted on the same server.
77    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    /// Routes serving the discovery document and the JWKS.
85    pub fn routes<S>(&self) -> Router<S>
86    where
87        S: Clone + Send + Sync + 'static,
88    {
89        let discovery = self.discovery.clone();
90        // Serialize the JWKS once; serve it with the RFC 7517 media type.
91        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
116/// Build the OpenID Provider metadata document from config.
117fn 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
147/// The path component of a (possibly absolute) JWKS URI.
148fn jwks_uri_path(uri: &str) -> String {
149    // Strip scheme://host if present, keep the path (default to a well-known).
150    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
162/// Convert an Ed25519 public-key PEM into an OKP JWK.
163fn 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    // An Ed25519 SPKI is exactly the 12-byte prefix + 32-byte key. Verify both,
173    // so an RSA/EC key (which would also be longer than 32 bytes) is rejected
174    // loudly instead of having its tail bytes published as a bogus Ed25519 key.
175    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
193/// Decode the base64 body of a PEM block.
194fn 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
205/// Stable key id: the first 16 hex chars of the SHA-256 of the public key.
206fn key_id(raw: &[u8]) -> String {
207    let digest = Sha256::digest(raw);
208    hex16(&digest)
209}
210
211/// Render the first 8 bytes of a digest as lowercase hex.
212fn 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        // Absent endpoints are omitted, not null.
265        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        // x is the 32-byte key, base64url without padding (43 chars).
297        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    // An EC P-256 public key (91-byte SPKI) that the loose length check would
308    // accept, publishing its last 32 bytes as a bogus Ed25519 key.
309    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        // EdDSA configured but the PEM is an EC key: must be a hard error, not a
325        // silently-published wrong key.
326        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        // No signing key, but the discovery doc still advertises a local
400        // jwks_uri, so that path must resolve (empty set), not 404.
401        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}