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            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
185/// Build the JWT claim object for an access_token. Carries `client_id`
186/// and `scope` so resource servers can authorize without an extra
187/// lookup. Same `sid` as the id_token so revocation can fan out.
188pub 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
209/// Mint a stable opaque session id for the SSO session row (`sid`
210/// claim).
211pub 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        // base64url(64 bytes, no pad) = ceil(64*8/6) = 86 chars + 4 prefix
263        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        // Only openid scope, no email / name claims should leak through.
334        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}