1use serde::{Deserialize, Serialize};
33
34#[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 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#[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 pub n: String,
99 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#[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 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 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 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, });
256 assert!(store.take("old").is_none());
257 }
258}