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 claims["email"] = serde_json::Value::String(e.to_string());
176 claims["email_verified"] = serde_json::Value::Bool(email_verified);
177 }
178 if scopes.iter().any(|s| s == "profile")
179 && let Some(n) = name {
180 claims["name"] = serde_json::Value::String(n.to_string());
181 }
182 claims
183}
184
185pub fn build_access_token_claims(
189 issuer: &str,
190 user_id: &str,
191 client_id: &str,
192 sid: &str,
193 scopes: &[String],
194) -> serde_json::Value {
195 let now = now_secs();
196 serde_json::json!({
197 "iss": issuer,
198 "sub": user_id,
199 "aud": client_id,
200 "client_id": client_id,
201 "iat": now as i64,
202 "exp": (now + ACCESS_TOKEN_LIFETIME_SECS) as i64,
203 "sid": sid,
204 "scope": scopes.join(" "),
205 "token_use": "access",
206 })
207}
208
209pub fn mint_sid() -> String {
212 let mut buf = [0u8; 16];
213 rand::rng().fill_bytes(&mut buf);
214 format!("sid_{}", data_encoding::BASE64URL_NOPAD.encode(&buf))
215}
216
217fn now_secs() -> f64 {
218 SystemTime::now()
219 .duration_since(UNIX_EPOCH)
220 .unwrap_or_default()
221 .as_secs_f64()
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn pkce_s256_round_trip() {
230 let verifier = "test-verifier-with-some-entropy-bytes";
231 let mut h = Sha256::new();
232 h.update(verifier.as_bytes());
233 let challenge = data_encoding::BASE64URL_NOPAD.encode(&h.finalize());
234 assert!(verify_pkce_s256(verifier, &challenge));
235 }
236
237 #[test]
238 fn pkce_s256_rejects_wrong_verifier() {
239 let mut h = Sha256::new();
240 h.update(b"correct");
241 let challenge = data_encoding::BASE64URL_NOPAD.encode(&h.finalize());
242 assert!(!verify_pkce_s256("wrong", &challenge));
243 }
244
245 #[test]
246 fn pkce_s256_rejects_empty() {
247 assert!(!verify_pkce_s256("", "abc"));
248 assert!(!verify_pkce_s256("abc", ""));
249 }
250
251 #[test]
252 fn refresh_token_hash_is_deterministic() {
253 let t = "ort_abcdef";
254 assert_eq!(hash_refresh_token(t), hash_refresh_token(t));
255 assert_ne!(hash_refresh_token(t), hash_refresh_token("ort_abcdeg"));
256 }
257
258 #[test]
259 fn mint_refresh_token_starts_with_marker() {
260 let t = mint_refresh_token();
261 assert!(t.starts_with("ort_"));
262 assert!(t.len() >= 60);
264 }
265
266 #[test]
267 fn build_refresh_row_hashes_plaintext_and_sets_expiry() {
268 let plaintext = "ort_xyz";
269 let row = build_refresh_row("u1", "c1", &["openid".to_string()], plaintext);
270 assert_eq!(row.token_hash, hash_refresh_token(plaintext));
271 assert_eq!(row.user_id, "u1");
272 assert_eq!(row.client_id, "c1");
273 assert!((row.expires_at - row.issued_at - REFRESH_TOKEN_LIFETIME_SECS).abs() < 1.0);
274 assert!(!row.revoked);
275 }
276
277 #[test]
278 fn id_token_claims_carry_required_oidc_fields() {
279 let scopes = vec!["openid".to_string(), "email".to_string()];
280 let v = build_id_token_claims(
281 "https://idp.example.com",
282 "user_alice",
283 "client_app",
284 "sid_x",
285 &scopes,
286 Some("nonce_y"),
287 Some("alice@example.com"),
288 true,
289 None,
290 );
291 assert_eq!(v["iss"], "https://idp.example.com");
292 assert_eq!(v["sub"], "user_alice");
293 assert_eq!(v["aud"], "client_app");
294 assert_eq!(v["sid"], "sid_x");
295 assert_eq!(v["nonce"], "nonce_y");
296 assert_eq!(v["email"], "alice@example.com");
297 assert_eq!(v["email_verified"], true);
298 assert!(v.get("name").is_none(), "no profile scope, no name");
299 }
300
301 #[test]
302 fn id_token_claims_with_profile_scope_emits_name() {
303 let scopes = vec!["openid".to_string(), "profile".to_string()];
304 let v = build_id_token_claims(
305 "https://idp",
306 "u",
307 "c",
308 "sid",
309 &scopes,
310 None,
311 None,
312 false,
313 Some("Alice Liddell"),
314 );
315 assert_eq!(v["name"], "Alice Liddell");
316 assert!(v.get("email").is_none());
317 }
318
319 #[test]
320 fn id_token_minimal_when_only_openid() {
321 let scopes = vec!["openid".to_string()];
322 let v = build_id_token_claims(
323 "https://idp",
324 "u",
325 "c",
326 "sid",
327 &scopes,
328 None,
329 Some("dropped@example.com"),
330 true,
331 Some("dropped name"),
332 );
333 assert!(v.get("email").is_none());
335 assert!(v.get("email_verified").is_none());
336 assert!(v.get("name").is_none());
337 }
338
339 #[test]
340 fn access_token_claims_include_scope_string() {
341 let scopes = vec!["openid".to_string(), "email".to_string()];
342 let v = build_access_token_claims(
343 "https://idp",
344 "u",
345 "c",
346 "sid",
347 &scopes,
348 );
349 assert_eq!(v["scope"], "openid email");
350 assert_eq!(v["client_id"], "c");
351 assert_eq!(v["token_use"], "access");
352 }
353
354 #[test]
355 fn mint_sid_starts_with_marker() {
356 let s = mint_sid();
357 assert!(s.starts_with("sid_"));
358 }
359}