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!["openid".into(), "email".into(), "profile".into()],
66            token_endpoint_auth_methods_supported: vec![
67                "client_secret_post".into(),
68                "client_secret_basic".into(),
69            ],
70            claims_supported: vec![
71                "sub".into(),
72                "email".into(),
73                "email_verified".into(),
74                "name".into(),
75                "preferred_username".into(),
76                "picture".into(),
77            ],
78        }
79    }
80}
81
82/// Single JWK entry for the JWKS doc. Pylon currently only emits
83/// one RSA key at a time but the JWKS array shape lets you rotate
84/// (publish old + new together for one signing-window) without
85/// breaking in-flight tokens.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Jwk {
88    pub kty: String,
89    pub alg: String,
90    #[serde(rename = "use")]
91    pub use_: String,
92    pub kid: String,
93    /// Modulus, base64url-no-pad. RSA-only.
94    pub n: String,
95    /// Exponent, base64url-no-pad. RSA-only.
96    pub e: String,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Jwks {
101    pub keys: Vec<Jwk>,
102}
103
104impl Jwks {
105    pub fn one(key: Jwk) -> Self {
106        Self { keys: vec![key] }
107    }
108}
109
110/// Minimal pending-authcode store. Pylon-issued auth codes are
111/// random 32-byte tokens, single-use, 10-minute expiry. The stored
112/// value carries the `(user_id, client_id, redirect_uri, scopes,
113/// nonce, code_challenge?)` tuple so /token can re-bind the
114/// originating /authorize request.
115#[derive(Debug, Clone)]
116pub struct AuthCode {
117    pub code: String,
118    pub user_id: String,
119    pub client_id: String,
120    pub redirect_uri: String,
121    pub scopes: Vec<String>,
122    pub nonce: Option<String>,
123    pub code_challenge: Option<String>,
124    pub code_challenge_method: Option<String>,
125    pub expires_at: u64,
126}
127
128pub struct AuthCodeStore {
129    codes: std::sync::Mutex<std::collections::HashMap<String, AuthCode>>,
130}
131
132impl Default for AuthCodeStore {
133    fn default() -> Self {
134        Self {
135            codes: std::sync::Mutex::new(std::collections::HashMap::new()),
136        }
137    }
138}
139
140impl AuthCodeStore {
141    pub fn new() -> Self {
142        Self::default()
143    }
144
145    pub fn put(&self, code: AuthCode) {
146        self.codes.lock().unwrap().insert(code.code.clone(), code);
147    }
148
149    /// Atomically take a code (single-use). Returns `None` for
150    /// unknown / expired codes.
151    pub fn take(&self, code: &str) -> Option<AuthCode> {
152        let mut map = self.codes.lock().unwrap();
153        let entry = map.remove(code)?;
154        if entry.expires_at <= now_secs() {
155            return None;
156        }
157        Some(entry)
158    }
159}
160
161fn now_secs() -> u64 {
162    use std::time::{SystemTime, UNIX_EPOCH};
163    SystemTime::now()
164        .duration_since(UNIX_EPOCH)
165        .unwrap_or_default()
166        .as_secs()
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn discovery_doc_uses_issuer_for_endpoints() {
175        let doc = DiscoveryDoc::for_issuer("https://auth.example.com");
176        assert_eq!(doc.issuer, "https://auth.example.com");
177        assert_eq!(
178            doc.authorization_endpoint,
179            "https://auth.example.com/oidc/authorize"
180        );
181        assert_eq!(doc.token_endpoint, "https://auth.example.com/oidc/token");
182        assert_eq!(doc.jwks_uri, "https://auth.example.com/oidc/jwks");
183        assert!(doc
184            .id_token_signing_alg_values_supported
185            .contains(&"RS256".to_string()));
186    }
187
188    #[test]
189    fn discovery_doc_strips_trailing_slash() {
190        let doc = DiscoveryDoc::for_issuer("https://auth.example.com/");
191        assert_eq!(doc.issuer, "https://auth.example.com");
192        assert!(doc.token_endpoint.ends_with("/oidc/token"));
193        assert!(!doc.token_endpoint.contains("//oidc"));
194    }
195
196    #[test]
197    fn discovery_doc_serializes_to_json() {
198        let doc = DiscoveryDoc::for_issuer("https://auth.example.com");
199        let json = serde_json::to_string(&doc).unwrap();
200        assert!(json.contains("\"issuer\""));
201        assert!(json.contains("\"jwks_uri\""));
202        assert!(json.contains("\"response_types_supported\""));
203    }
204
205    #[test]
206    fn jwks_serializes_canonical_shape() {
207        let jwks = Jwks::one(Jwk {
208            kty: "RSA".into(),
209            alg: "RS256".into(),
210            use_: "sig".into(),
211            kid: "key-1".into(),
212            n: "modulus_b64url".into(),
213            e: "AQAB".into(),
214        });
215        let json = serde_json::to_string(&jwks).unwrap();
216        // `use` is a reserved keyword — verify the rename worked.
217        assert!(json.contains("\"use\":\"sig\""));
218        assert!(json.contains("\"kty\":\"RSA\""));
219        assert!(json.contains("\"alg\":\"RS256\""));
220    }
221
222    #[test]
223    fn auth_code_store_round_trip() {
224        let store = AuthCodeStore::new();
225        let code = AuthCode {
226            code: "tok123".into(),
227            user_id: "u1".into(),
228            client_id: "c1".into(),
229            redirect_uri: "https://app/cb".into(),
230            scopes: vec!["openid".into()],
231            nonce: Some("n".into()),
232            code_challenge: None,
233            code_challenge_method: None,
234            expires_at: 9_999_999_999,
235        };
236        store.put(code.clone());
237        let taken = store.take("tok123").unwrap();
238        assert_eq!(taken.user_id, "u1");
239        // Single-use.
240        assert!(store.take("tok123").is_none());
241    }
242
243    #[test]
244    fn auth_code_expired_rejected() {
245        let store = AuthCodeStore::new();
246        store.put(AuthCode {
247            code: "old".into(),
248            user_id: "u1".into(),
249            client_id: "c1".into(),
250            redirect_uri: "x".into(),
251            scopes: vec![],
252            nonce: None,
253            code_challenge: None,
254            code_challenge_method: None,
255            expires_at: 1, // ancient
256        });
257        assert!(store.take("old").is_none());
258    }
259}