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    /// Routes serving the discovery document and the JWKS.
76    pub fn routes<S>(&self) -> Router<S>
77    where
78        S: Clone + Send + Sync + 'static,
79    {
80        let discovery = self.discovery.clone();
81        // Serialize the JWKS once; serve it with the RFC 7517 media type.
82        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
107/// Build the OpenID Provider metadata document from config.
108fn 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
138/// The path component of a (possibly absolute) JWKS URI.
139fn jwks_uri_path(uri: &str) -> String {
140    // Strip scheme://host if present, keep the path (default to a well-known).
141    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
153/// Convert an Ed25519 public-key PEM into an OKP JWK.
154fn 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    // An Ed25519 SPKI is exactly the 12-byte prefix + 32-byte key. Verify both,
164    // so an RSA/EC key (which would also be longer than 32 bytes) is rejected
165    // loudly instead of having its tail bytes published as a bogus Ed25519 key.
166    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
184/// Decode the base64 body of a PEM block.
185fn 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
196/// Stable key id: the first 16 hex chars of the SHA-256 of the public key.
197fn key_id(raw: &[u8]) -> String {
198    let digest = Sha256::digest(raw);
199    hex16(&digest)
200}
201
202/// Render the first 8 bytes of a digest as lowercase hex.
203fn 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        // Absent endpoints are omitted, not null.
256        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        // x is the 32-byte key, base64url without padding (43 chars).
288        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    // An EC P-256 public key (91-byte SPKI) that the loose length check would
299    // accept, publishing its last 32 bytes as a bogus Ed25519 key.
300    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        // EdDSA configured but the PEM is an EC key: must be a hard error, not a
316        // silently-published wrong key.
317        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        // No signing key, but the discovery doc still advertises a local
391        // jwks_uri, so that path must resolve (empty set), not 404.
392        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}