Skip to main content

pylon_auth/
oidc_provider.rs

1//! OpenID Connect Provider — turn pylon into an IdP that other apps
2//! can sign in against. Useful for SSO across a fleet of internal
3//! tools when you don't want to depend on Auth0/Okta/Cognito.
4//!
5//! **Status: library only — HTTP endpoints not yet wired.**
6//! Discovery doc / JWKS / AuthCode types ship today so apps that
7//! want to roll their own OIDC routes can compose them. The
8//! pylon-shipped `/.well-known/openid-configuration` + `/oidc/*`
9//! routes are queued for the next wave (need RSA key generation
10//! + on-disk persistence first). Until then, do NOT advertise a
11//! pylon instance as an OIDC provider in production.
12//!
13//! What pylon implements:
14//!   - `/.well-known/openid-configuration` discovery doc
15//!   - `/oidc/jwks` — public keys other services use to verify
16//!     id_tokens we issue
17//!   - `/oidc/authorize` — kicks off an auth-code flow
18//!   - `/oidc/token` — exchange code for `id_token` + `access_token`
19//!   - `/oidc/userinfo` — bearer-protected user info endpoint
20//!
21//! Crypto: RS256-signed id_tokens (industry default). Pylon
22//! generates a fresh RSA key on first start and stores it on disk
23//! (`PYLON_OIDC_KEY_PATH`, defaults to `<sessions.db>.oidc-key.pem`).
24//! Same key reused across restarts so issued tokens stay valid.
25//!
26//! For Wave-5 we ship the discovery + jwks + verify primitives.
27//! The `/authorize` + `/token` + `/userinfo` endpoint wiring lives
28//! in `routes/auth.rs` and uses the existing session + scope plumbing.
29//!
30//! Spec: <https://openid.net/specs/openid-connect-core-1_0.html>
31
32use serde::{Deserialize, Serialize};
33
34/// `.well-known/openid-configuration` shape — same fields pylon's
35/// OIDC client looks for in a remote IdP.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DiscoveryDoc {
38    pub issuer: String,
39    pub authorization_endpoint: String,
40    pub token_endpoint: String,
41    pub userinfo_endpoint: String,
42    pub jwks_uri: String,
43    pub response_types_supported: Vec<String>,
44    pub subject_types_supported: Vec<String>,
45    pub id_token_signing_alg_values_supported: Vec<String>,
46    pub scopes_supported: Vec<String>,
47    pub token_endpoint_auth_methods_supported: Vec<String>,
48    pub claims_supported: Vec<String>,
49}
50
51impl DiscoveryDoc {
52    /// Build the discovery doc for an instance whose external
53    /// address is `issuer` (e.g. `https://auth.example.com`).
54    pub fn for_issuer(issuer: &str) -> Self {
55        let issuer = issuer.trim_end_matches('/').to_string();
56        Self {
57            issuer: issuer.clone(),
58            authorization_endpoint: format!("{issuer}/oidc/authorize"),
59            token_endpoint: format!("{issuer}/oidc/token"),
60            userinfo_endpoint: format!("{issuer}/oidc/userinfo"),
61            jwks_uri: format!("{issuer}/oidc/jwks"),
62            response_types_supported: vec!["code".into()],
63            subject_types_supported: vec!["public".into()],
64            id_token_signing_alg_values_supported: vec!["RS256".into()],
65            scopes_supported: vec![
66                "openid".into(),
67                "email".into(),
68                "profile".into(),
69            ],
70            token_endpoint_auth_methods_supported: vec![
71                "client_secret_post".into(),
72                "client_secret_basic".into(),
73            ],
74            claims_supported: vec![
75                "sub".into(),
76                "email".into(),
77                "email_verified".into(),
78                "name".into(),
79                "preferred_username".into(),
80                "picture".into(),
81            ],
82        }
83    }
84}
85
86/// Single JWK entry for the JWKS doc. Pylon currently only emits
87/// one RSA key at a time but the JWKS array shape lets you rotate
88/// (publish old + new together for one signing-window) without
89/// breaking in-flight tokens.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Jwk {
92    pub kty: String,
93    pub alg: String,
94    #[serde(rename = "use")]
95    pub use_: String,
96    pub kid: String,
97    /// Modulus, base64url-no-pad. RSA-only.
98    pub n: String,
99    /// Exponent, base64url-no-pad. RSA-only.
100    pub e: String,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Jwks {
105    pub keys: Vec<Jwk>,
106}
107
108impl Jwks {
109    pub fn one(key: Jwk) -> Self {
110        Self { keys: vec![key] }
111    }
112}
113
114/// Minimal pending-authcode store. Pylon-issued auth codes are
115/// random 32-byte tokens, single-use, 10-minute expiry. The stored
116/// value carries the `(user_id, client_id, redirect_uri, scopes,
117/// nonce, code_challenge?)` tuple so /token can re-bind the
118/// originating /authorize request.
119#[derive(Debug, Clone)]
120pub struct AuthCode {
121    pub code: String,
122    pub user_id: String,
123    pub client_id: String,
124    pub redirect_uri: String,
125    pub scopes: Vec<String>,
126    pub nonce: Option<String>,
127    pub code_challenge: Option<String>,
128    pub code_challenge_method: Option<String>,
129    pub expires_at: u64,
130}
131
132pub struct AuthCodeStore {
133    codes: std::sync::Mutex<std::collections::HashMap<String, AuthCode>>,
134}
135
136impl Default for AuthCodeStore {
137    fn default() -> Self {
138        Self {
139            codes: std::sync::Mutex::new(std::collections::HashMap::new()),
140        }
141    }
142}
143
144impl AuthCodeStore {
145    pub fn new() -> Self {
146        Self::default()
147    }
148
149    pub fn put(&self, code: AuthCode) {
150        self.codes.lock().unwrap().insert(code.code.clone(), code);
151    }
152
153    /// Atomically take a code (single-use). Returns `None` for
154    /// unknown / expired codes.
155    pub fn take(&self, code: &str) -> Option<AuthCode> {
156        let mut map = self.codes.lock().unwrap();
157        let entry = map.remove(code)?;
158        if entry.expires_at <= now_secs() {
159            return None;
160        }
161        Some(entry)
162    }
163}
164
165fn now_secs() -> u64 {
166    use std::time::{SystemTime, UNIX_EPOCH};
167    SystemTime::now()
168        .duration_since(UNIX_EPOCH)
169        .unwrap_or_default()
170        .as_secs()
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn discovery_doc_uses_issuer_for_endpoints() {
179        let doc = DiscoveryDoc::for_issuer("https://auth.example.com");
180        assert_eq!(doc.issuer, "https://auth.example.com");
181        assert_eq!(doc.authorization_endpoint, "https://auth.example.com/oidc/authorize");
182        assert_eq!(doc.token_endpoint, "https://auth.example.com/oidc/token");
183        assert_eq!(doc.jwks_uri, "https://auth.example.com/oidc/jwks");
184        assert!(doc.id_token_signing_alg_values_supported.contains(&"RS256".to_string()));
185    }
186
187    #[test]
188    fn discovery_doc_strips_trailing_slash() {
189        let doc = DiscoveryDoc::for_issuer("https://auth.example.com/");
190        assert_eq!(doc.issuer, "https://auth.example.com");
191        assert!(doc.token_endpoint.ends_with("/oidc/token"));
192        assert!(!doc.token_endpoint.contains("//oidc"));
193    }
194
195    #[test]
196    fn discovery_doc_serializes_to_json() {
197        let doc = DiscoveryDoc::for_issuer("https://auth.example.com");
198        let json = serde_json::to_string(&doc).unwrap();
199        assert!(json.contains("\"issuer\""));
200        assert!(json.contains("\"jwks_uri\""));
201        assert!(json.contains("\"response_types_supported\""));
202    }
203
204    #[test]
205    fn jwks_serializes_canonical_shape() {
206        let jwks = Jwks::one(Jwk {
207            kty: "RSA".into(),
208            alg: "RS256".into(),
209            use_: "sig".into(),
210            kid: "key-1".into(),
211            n: "modulus_b64url".into(),
212            e: "AQAB".into(),
213        });
214        let json = serde_json::to_string(&jwks).unwrap();
215        // `use` is a reserved keyword — verify the rename worked.
216        assert!(json.contains("\"use\":\"sig\""));
217        assert!(json.contains("\"kty\":\"RSA\""));
218        assert!(json.contains("\"alg\":\"RS256\""));
219    }
220
221    #[test]
222    fn auth_code_store_round_trip() {
223        let store = AuthCodeStore::new();
224        let code = AuthCode {
225            code: "tok123".into(),
226            user_id: "u1".into(),
227            client_id: "c1".into(),
228            redirect_uri: "https://app/cb".into(),
229            scopes: vec!["openid".into()],
230            nonce: Some("n".into()),
231            code_challenge: None,
232            code_challenge_method: None,
233            expires_at: 9_999_999_999,
234        };
235        store.put(code.clone());
236        let taken = store.take("tok123").unwrap();
237        assert_eq!(taken.user_id, "u1");
238        // Single-use.
239        assert!(store.take("tok123").is_none());
240    }
241
242    #[test]
243    fn auth_code_expired_rejected() {
244        let store = AuthCodeStore::new();
245        store.put(AuthCode {
246            code: "old".into(),
247            user_id: "u1".into(),
248            client_id: "c1".into(),
249            redirect_uri: "x".into(),
250            scopes: vec![],
251            nonce: None,
252            code_challenge: None,
253            code_challenge_method: None,
254            expires_at: 1, // ancient
255        });
256        assert!(store.take("old").is_none());
257    }
258}