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!["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#[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 pub n: String,
95 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#[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 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 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 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, });
257 assert!(store.take("old").is_none());
258 }
259}