Skip to main content

assay_auth/oidc_provider/
token.rs

1//! `/token` — OIDC token endpoint.
2//!
3//! Implements the `authorization_code` and `refresh_token` grants per
4//! OIDC Core §3.1.3 / §12. The `client_credentials` grant is part of
5//! the V1 surface (the trait shape leaves room) but the v0.2.0 plan
6//! defers the implementation; the handler returns
7//! `unsupported_grant_type` until phase 8 wires it.
8//!
9//! Bearer tokens (id_token / access_token) are EdDSA-signed JWTs minted
10//! through the existing [`crate::jwt::JwtConfig`]. Refresh tokens are
11//! opaque base64url strings — only their SHA-256 hash round-trips the
12//! DB, the bearer itself is returned to the consumer once at issue
13//! time.
14
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use rand::RngCore;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use super::types::RefreshToken;
22
23/// Default access_token / id_token lifetime — one hour.
24pub const ACCESS_TOKEN_LIFETIME_SECS: f64 = 3600.0;
25/// Default refresh_token lifetime — 30 days.
26pub const REFRESH_TOKEN_LIFETIME_SECS: f64 = 60.0 * 60.0 * 24.0 * 30.0;
27
28/// Form-encoded request body for `POST /token`.
29#[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/// Successful response body. `expires_in` is seconds-from-now matching
42/// the access_token's `exp` claim (RFC 6749 §5.1).
43#[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/// Error response body — wire-compatible with OAuth 2 §5.2.
55#[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
62/// Stable OAuth2 / OIDC token-endpoint error codes.
63pub 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
73/// Verify a PKCE `code_verifier` against the stored `code_challenge`
74/// (S256 only). Returns `true` on match. Rejects empty inputs early.
75pub 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
86/// Constant-time bytewise compare. Length differences short-circuit
87/// (this is fine — equal-length is the only meaningful side channel).
88fn 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
99/// SHA-256 hex of `token`. Used as the `auth.oidc_refresh_tokens.token_hash`
100/// primary key — the bearer value never round-trips the DB in plaintext.
101pub 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
108/// Mint a fresh opaque refresh token. 64 bytes base64url ≈ 86 chars
109/// (no padding), which is the typical opaque-token length consumer apps
110/// expect.
111pub 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
117/// Build a fresh [`RefreshToken`] row for `user_id` + `client_id`.
118/// Caller separately stores the plaintext bearer to return in the
119/// response — only the hash lives in the row.
120pub 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/// Build the JWT claim object for an id_token.
139///
140/// `sid` ties the token to the SSO session row in `auth.oidc_sessions`
141/// — back-channel logout uses it.
142///
143/// We emit per-scope optional claims:
144/// - `email` scope → `email` + `email_verified`
145/// - `profile` scope → `name`
146// All these fields appear directly as JWT claims, so a struct wrapper
147// would just shuffle the same bytes around. Reads cleanly at the call
148// sites in token.rs's grant handlers.
149#[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
187/// Build the JWT claim object for an access_token. Carries `client_id`
188/// and `scope` so resource servers can authorize without an extra
189/// lookup. Same `sid` as the id_token so revocation can fan out.
190pub 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
211/// Mint a stable opaque session id for the SSO session row (`sid`
212/// claim).
213pub 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        // base64url(64 bytes, no pad) = ceil(64*8/6) = 86 chars + 4 prefix
265        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        // Only openid scope, no email / name claims should leak through.
336        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}