assay_auth/oidc_provider/
token.rs1use std::time::{SystemTime, UNIX_EPOCH};
16
17use rand::RngCore;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use super::types::RefreshToken;
22
23pub const ACCESS_TOKEN_LIFETIME_SECS: f64 = 3600.0;
25pub const REFRESH_TOKEN_LIFETIME_SECS: f64 = 60.0 * 60.0 * 24.0 * 30.0;
27
28#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
30pub struct TokenRequest {
31 pub grant_type: String,
32 pub code: Option<String>,
33 pub redirect_uri: Option<String>,
34 pub client_id: Option<String>,
35 pub client_secret: Option<String>,
36 pub code_verifier: Option<String>,
37 pub refresh_token: Option<String>,
38 pub scope: Option<String>,
39}
40
41#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
44pub struct TokenResponse {
45 pub access_token: String,
46 pub token_type: &'static str,
47 pub expires_in: i64,
48 pub id_token: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub refresh_token: Option<String>,
51 pub scope: String,
52}
53
54#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
56pub struct TokenErrorBody {
57 pub error: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub error_description: Option<String>,
60}
61
62pub mod errors {
64 pub const INVALID_REQUEST: &str = "invalid_request";
65 pub const INVALID_CLIENT: &str = "invalid_client";
66 pub const INVALID_GRANT: &str = "invalid_grant";
67 pub const UNAUTHORIZED_CLIENT: &str = "unauthorized_client";
68 pub const UNSUPPORTED_GRANT_TYPE: &str = "unsupported_grant_type";
69 pub const INVALID_SCOPE: &str = "invalid_scope";
70 pub const SERVER_ERROR: &str = "server_error";
71}
72
73pub fn verify_pkce_s256(verifier: &str, challenge: &str) -> bool {
76 if verifier.is_empty() || challenge.is_empty() {
77 return false;
78 }
79 let mut hasher = Sha256::new();
80 hasher.update(verifier.as_bytes());
81 let digest = hasher.finalize();
82 let computed = data_encoding::BASE64URL_NOPAD.encode(&digest);
83 constant_time_eq(computed.as_bytes(), challenge.as_bytes())
84}
85
86fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
89 if a.len() != b.len() {
90 return false;
91 }
92 let mut diff = 0u8;
93 for (x, y) in a.iter().zip(b.iter()) {
94 diff |= x ^ y;
95 }
96 diff == 0
97}
98
99pub fn hash_refresh_token(token: &str) -> String {
102 let mut h = Sha256::new();
103 h.update(token.as_bytes());
104 let d = h.finalize();
105 data_encoding::HEXLOWER.encode(&d)
106}
107
108pub fn mint_refresh_token() -> String {
112 let mut buf = [0u8; 64];
113 rand::rng().fill_bytes(&mut buf);
114 format!("ort_{}", data_encoding::BASE64URL_NOPAD.encode(&buf))
115}
116
117pub fn build_refresh_row(
121 user_id: &str,
122 client_id: &str,
123 scopes: &[String],
124 plaintext: &str,
125) -> RefreshToken {
126 let now = now_secs();
127 RefreshToken {
128 token_hash: hash_refresh_token(plaintext),
129 client_id: client_id.to_string(),
130 user_id: user_id.to_string(),
131 scopes: scopes.to_vec(),
132 issued_at: now,
133 expires_at: now + REFRESH_TOKEN_LIFETIME_SECS,
134 revoked: false,
135 }
136}
137
138#[allow(clippy::too_many_arguments)]
150pub fn build_id_token_claims(
151 issuer: &str,
152 user_id: &str,
153 client_id: &str,
154 sid: &str,
155 scopes: &[String],
156 nonce: Option<&str>,
157 email: Option<&str>,
158 email_verified: bool,
159 name: Option<&str>,
160) -> serde_json::Value {
161 let now = now_secs();
162 let mut claims = serde_json::json!({
163 "iss": issuer,
164 "sub": user_id,
165 "aud": client_id,
166 "iat": now as i64,
167 "exp": (now + ACCESS_TOKEN_LIFETIME_SECS) as i64,
168 "sid": sid,
169 });
170 if let Some(n) = nonce {
171 claims["nonce"] = serde_json::Value::String(n.to_string());
172 }
173 if scopes.iter().any(|s| s == "email")
174 && let Some(e) = email
175 {
176 claims["email"] = serde_json::Value::String(e.to_string());
177 claims["email_verified"] = serde_json::Value::Bool(email_verified);
178 }
179 if scopes.iter().any(|s| s == "profile")
180 && let Some(n) = name
181 {
182 claims["name"] = serde_json::Value::String(n.to_string());
183 }
184 claims
185}
186
187pub fn build_access_token_claims(
191 issuer: &str,
192 user_id: &str,
193 client_id: &str,
194 sid: &str,
195 scopes: &[String],
196) -> serde_json::Value {
197 let now = now_secs();
198 serde_json::json!({
199 "iss": issuer,
200 "sub": user_id,
201 "aud": client_id,
202 "client_id": client_id,
203 "iat": now as i64,
204 "exp": (now + ACCESS_TOKEN_LIFETIME_SECS) as i64,
205 "sid": sid,
206 "scope": scopes.join(" "),
207 "token_use": "access",
208 })
209}
210
211pub fn mint_sid() -> String {
214 let mut buf = [0u8; 16];
215 rand::rng().fill_bytes(&mut buf);
216 format!("sid_{}", data_encoding::BASE64URL_NOPAD.encode(&buf))
217}
218
219fn now_secs() -> f64 {
220 SystemTime::now()
221 .duration_since(UNIX_EPOCH)
222 .unwrap_or_default()
223 .as_secs_f64()
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn pkce_s256_round_trip() {
232 let verifier = "test-verifier-with-some-entropy-bytes";
233 let mut h = Sha256::new();
234 h.update(verifier.as_bytes());
235 let challenge = data_encoding::BASE64URL_NOPAD.encode(&h.finalize());
236 assert!(verify_pkce_s256(verifier, &challenge));
237 }
238
239 #[test]
240 fn pkce_s256_rejects_wrong_verifier() {
241 let mut h = Sha256::new();
242 h.update(b"correct");
243 let challenge = data_encoding::BASE64URL_NOPAD.encode(&h.finalize());
244 assert!(!verify_pkce_s256("wrong", &challenge));
245 }
246
247 #[test]
248 fn pkce_s256_rejects_empty() {
249 assert!(!verify_pkce_s256("", "abc"));
250 assert!(!verify_pkce_s256("abc", ""));
251 }
252
253 #[test]
254 fn refresh_token_hash_is_deterministic() {
255 let t = "ort_abcdef";
256 assert_eq!(hash_refresh_token(t), hash_refresh_token(t));
257 assert_ne!(hash_refresh_token(t), hash_refresh_token("ort_abcdeg"));
258 }
259
260 #[test]
261 fn mint_refresh_token_starts_with_marker() {
262 let t = mint_refresh_token();
263 assert!(t.starts_with("ort_"));
264 assert!(t.len() >= 60);
266 }
267
268 #[test]
269 fn build_refresh_row_hashes_plaintext_and_sets_expiry() {
270 let plaintext = "ort_xyz";
271 let row = build_refresh_row("u1", "c1", &["openid".to_string()], plaintext);
272 assert_eq!(row.token_hash, hash_refresh_token(plaintext));
273 assert_eq!(row.user_id, "u1");
274 assert_eq!(row.client_id, "c1");
275 assert!((row.expires_at - row.issued_at - REFRESH_TOKEN_LIFETIME_SECS).abs() < 1.0);
276 assert!(!row.revoked);
277 }
278
279 #[test]
280 fn id_token_claims_carry_required_oidc_fields() {
281 let scopes = vec!["openid".to_string(), "email".to_string()];
282 let v = build_id_token_claims(
283 "https://idp.example.com",
284 "user_alice",
285 "client_app",
286 "sid_x",
287 &scopes,
288 Some("nonce_y"),
289 Some("alice@example.com"),
290 true,
291 None,
292 );
293 assert_eq!(v["iss"], "https://idp.example.com");
294 assert_eq!(v["sub"], "user_alice");
295 assert_eq!(v["aud"], "client_app");
296 assert_eq!(v["sid"], "sid_x");
297 assert_eq!(v["nonce"], "nonce_y");
298 assert_eq!(v["email"], "alice@example.com");
299 assert_eq!(v["email_verified"], true);
300 assert!(v.get("name").is_none(), "no profile scope, no name");
301 }
302
303 #[test]
304 fn id_token_claims_with_profile_scope_emits_name() {
305 let scopes = vec!["openid".to_string(), "profile".to_string()];
306 let v = build_id_token_claims(
307 "https://idp",
308 "u",
309 "c",
310 "sid",
311 &scopes,
312 None,
313 None,
314 false,
315 Some("Alice Liddell"),
316 );
317 assert_eq!(v["name"], "Alice Liddell");
318 assert!(v.get("email").is_none());
319 }
320
321 #[test]
322 fn id_token_minimal_when_only_openid() {
323 let scopes = vec!["openid".to_string()];
324 let v = build_id_token_claims(
325 "https://idp",
326 "u",
327 "c",
328 "sid",
329 &scopes,
330 None,
331 Some("dropped@example.com"),
332 true,
333 Some("dropped name"),
334 );
335 assert!(v.get("email").is_none());
337 assert!(v.get("email_verified").is_none());
338 assert!(v.get("name").is_none());
339 }
340
341 #[test]
342 fn access_token_claims_include_scope_string() {
343 let scopes = vec!["openid".to_string(), "email".to_string()];
344 let v = build_access_token_claims("https://idp", "u", "c", "sid", &scopes);
345 assert_eq!(v["scope"], "openid email");
346 assert_eq!(v["client_id"], "c");
347 assert_eq!(v["token_use"], "access");
348 }
349
350 #[test]
351 fn mint_sid_starts_with_marker() {
352 let s = mint_sid();
353 assert!(s.starts_with("sid_"));
354 }
355}