Skip to main content

assay_auth/oidc_provider/
handlers.rs

1//! Concrete axum handlers for the OIDC provider.
2//!
3//! Phase 8 wiring — every helper module (authorize / token / userinfo /
4//! revoke / introspect / federation) ships pure validation/build logic;
5//! this file glues them to `axum::extract::State<AuthCtx>` so the
6//! engine binary's router can serve real HTTP responses.
7//!
8//! Conventions:
9//!
10//! - Every handler returns an `axum::response::Response` so we can mix
11//!   redirects, JSON bodies, and HTML pages without bespoke wrappers.
12//! - Errors map to `(StatusCode, Json<...>)` tuples shaped like the
13//!   relevant RFC's error body (OIDC Core, OAuth 2 §5.2, RFC 7009/7662).
14//! - Session cookies are read off the `Cookie` header by name; we don't
15//!   use `axum-extra::CookieJar` to keep the dep set lean.
16
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use axum::Form;
20use axum::extract::{Path, Query, State};
21use axum::http::{HeaderMap, StatusCode, header};
22use axum::response::{Html, IntoResponse, Json, Redirect, Response};
23use serde::Deserialize;
24use serde_json::json;
25
26use crate::ctx::AuthCtx;
27
28use super::authorize::{self as authz, AuthorizeRequest, AuthorizeValidation};
29use super::consent::{ConsentPage, ConsentSubmission, scopes_already_granted};
30use super::introspect::{IntrospectRequest, IntrospectResponse};
31use super::revoke::RevokeRequest;
32use super::token::{self as tok, TokenErrorBody, TokenRequest, TokenResponse, errors};
33use super::types::{ConsentGrant, OidcSession};
34use super::userinfo::{self, ProviderAccessTokenClaims};
35
36/// Cookie name carrying a transient "in-flight authorize request"
37/// payload. We base64-url-encode the original querystring so the
38/// consent POST can resume without persisting state.
39const RESUME_COOKIE: &str = "assay_oidc_resume";
40
41/// Cookie name carrying the raw upstream-OIDC binding token. Set on
42/// `/oidc/upstream/{slug}/start`'s 302; checked by `/callback` against
43/// the state row's `binding_hash`. Cleared on every callback response
44/// (success or failure).
45const UPSTREAM_BINDING_COOKIE: &str = "assay_oidc_binding";
46
47// =====================================================================
48//   /authorize
49// =====================================================================
50
51/// `GET /authorize` — orchestrates: validate → check session → render
52/// consent (or skip on prior grant) → mint code → 302 to the consumer.
53pub async fn authorize_get(
54    State(ctx): State<AuthCtx>,
55    headers: HeaderMap,
56    Query(req): Query<AuthorizeRequest>,
57) -> Response {
58    let provider = match ctx.oidc_provider.as_ref() {
59        Some(p) => p,
60        None => return server_misconfigured("oidc_provider is not enabled"),
61    };
62
63    // Look up the registered client.
64    let client = match provider.clients.get(&req.client_id).await {
65        Ok(Some(c)) => c,
66        Ok(None) => {
67            return error_html(
68                StatusCode::BAD_REQUEST,
69                &format!("unknown client_id {:?}", req.client_id),
70            );
71        }
72        Err(e) => return server_error_html(&format!("client lookup failed: {e}")),
73    };
74
75    // Pure validation against the registered client.
76    match authz::validate(&req, &client) {
77        AuthorizeValidation::Ok { scopes } => {
78            authorize_post_validate(ctx.clone(), &headers, req, client, scopes).await
79        }
80        AuthorizeValidation::Fatal { reason } => error_html(StatusCode::BAD_REQUEST, &reason),
81        AuthorizeValidation::Redirect { error, description } => {
82            Redirect::to(&authz::redirect_with_error(
83                &req.redirect_uri,
84                error,
85                &description,
86                req.state.as_deref(),
87            ))
88            .into_response()
89        }
90    }
91}
92
93/// Branch of [`authorize_get`] for the validated path. Resolves the
94/// session cookie; if no live session, redirects to `/auth/login`. If
95/// authenticated, mints either the consent page or the code directly.
96async fn authorize_post_validate(
97    ctx: AuthCtx,
98    headers: &HeaderMap,
99    req: AuthorizeRequest,
100    client: super::types::OidcClient,
101    scopes: Vec<String>,
102) -> Response {
103    // Resolve the session cookie.
104    let session_id = parse_cookie(headers, crate::session::SESSION_COOKIE);
105    let session = match session_id {
106        Some(sid) => match ctx.sessions.get(&sid).await {
107            Ok(Some(s)) if s.expires_at > now_secs() => Some(s),
108            _ => None,
109        },
110        None => None,
111    };
112
113    // No session → redirect to login with the original URL stashed.
114    let Some(session) = session else {
115        let original = rebuild_authorize_url(&ctx, &req);
116        return Redirect::to(&authz::return_to_for(&original)).into_response();
117    };
118
119    // Authenticated. Decide whether consent is required.
120    let provider = match ctx.oidc_provider.as_ref() {
121        Some(p) => p,
122        None => return server_misconfigured("oidc_provider is not enabled"),
123    };
124
125    let needs_consent = if !client.require_consent {
126        false
127    } else {
128        // Skip consent when the user has previously granted these (or
129        // wider) scopes for this client.
130        match provider
131            .consents
132            .get(&session.user_id, &client.client_id)
133            .await
134        {
135            Ok(Some(grant)) => !scopes_already_granted(&scopes, &grant.scopes),
136            _ => true,
137        }
138    };
139
140    if needs_consent {
141        let resume = encode_resume(&req);
142        let page = ConsentPage {
143            client_name: &client.name,
144            issuer: &provider.issuer,
145            scopes: &scopes,
146            csrf_token: &session.csrf_token,
147            resume_token: &resume,
148        };
149        let mut response = Html(page.render_html()).into_response();
150        // Set the resume cookie so the consent POST can rebuild the
151        // authorize request without trusting form payload alone.
152        if let Ok(value) = format!(
153            "{}={}; Path=/; HttpOnly; SameSite=Lax",
154            RESUME_COOKIE, resume
155        )
156        .parse()
157        {
158            response.headers_mut().append(header::SET_COOKIE, value);
159        }
160        return response;
161    }
162
163    // No consent required — issue the code.
164    issue_authorization_code(&ctx, req, &session.user_id, scopes).await
165}
166
167/// Common path: build + persist an [`AuthorizationCode`] row, then
168/// 302 the user back to the consumer's `redirect_uri`.
169async fn issue_authorization_code(
170    ctx: &AuthCtx,
171    req: AuthorizeRequest,
172    user_id: &str,
173    scopes: Vec<String>,
174) -> Response {
175    let provider = match ctx.oidc_provider.as_ref() {
176        Some(p) => p,
177        None => return server_misconfigured("oidc_provider is not enabled"),
178    };
179    let code = authz::build_code(user_id, &req, scopes);
180    if let Err(e) = provider.codes.create(&code).await {
181        return server_error_html(&format!("persist authorization code: {e}"));
182    }
183    let redirect = authz::redirect_with_code(&req.redirect_uri, &code.code, req.state.as_deref());
184    Redirect::to(&redirect).into_response()
185}
186
187/// Reconstruct the original `/authorize` URL so the post-login flow
188/// can resume. We use the OIDC provider's issuer as the base.
189fn rebuild_authorize_url(ctx: &AuthCtx, req: &AuthorizeRequest) -> String {
190    let issuer = ctx
191        .oidc_provider
192        .as_ref()
193        .map(|p| p.issuer.as_str())
194        .unwrap_or("");
195    let mut url = format!(
196        "{issuer}/authorize?response_type={}",
197        url_encode(&req.response_type)
198    );
199    url.push_str(&format!("&client_id={}", url_encode(&req.client_id)));
200    url.push_str(&format!("&redirect_uri={}", url_encode(&req.redirect_uri)));
201    url.push_str(&format!("&scope={}", url_encode(&req.scope)));
202    if let Some(s) = &req.state {
203        url.push_str(&format!("&state={}", url_encode(s)));
204    }
205    if let Some(n) = &req.nonce {
206        url.push_str(&format!("&nonce={}", url_encode(n)));
207    }
208    if let Some(c) = &req.code_challenge {
209        url.push_str(&format!("&code_challenge={}", url_encode(c)));
210    }
211    if let Some(m) = &req.code_challenge_method {
212        url.push_str(&format!("&code_challenge_method={}", url_encode(m)));
213    }
214    url
215}
216
217// =====================================================================
218//   /authorize/consent
219// =====================================================================
220
221/// `POST /authorize/consent` — user clicked Allow / Deny on the consent
222/// page. We rebuild the original `AuthorizeRequest` from the resume
223/// cookie, persist the consent (on Allow), then issue the code or the
224/// `error=access_denied` redirect.
225pub async fn consent_post(
226    State(ctx): State<AuthCtx>,
227    headers: HeaderMap,
228    Form(submission): Form<ConsentSubmission>,
229) -> Response {
230    let provider = match ctx.oidc_provider.as_ref() {
231        Some(p) => p,
232        None => return server_misconfigured("oidc_provider is not enabled"),
233    };
234
235    // Pull the resume payload from the cookie (defended against form
236    // tampering by anchoring on the cookie, not the form field).
237    let resume = match parse_cookie(&headers, RESUME_COOKIE) {
238        Some(c) => c,
239        None => {
240            return error_html(
241                StatusCode::BAD_REQUEST,
242                "consent flow has no resume token (cookie missing)",
243            );
244        }
245    };
246    let req = match decode_resume(&resume) {
247        Some(r) => r,
248        None => {
249            return error_html(
250                StatusCode::BAD_REQUEST,
251                "consent resume payload is malformed",
252            );
253        }
254    };
255
256    // CSRF: token must match the session's stored csrf_token.
257    let session = match parse_cookie(&headers, crate::session::SESSION_COOKIE) {
258        Some(sid) => ctx.sessions.get(&sid).await.ok().flatten(),
259        None => None,
260    };
261    let Some(session) = session else {
262        return error_html(StatusCode::UNAUTHORIZED, "no active session");
263    };
264    if session.csrf_token != submission.csrf_token {
265        return error_html(StatusCode::FORBIDDEN, "csrf mismatch");
266    }
267
268    // Look up the client + revalidate (it might have been deleted in
269    // the millisecond between authorize_get and now).
270    let client = match provider.clients.get(&req.client_id).await {
271        Ok(Some(c)) => c,
272        _ => return error_html(StatusCode::BAD_REQUEST, "unknown client_id"),
273    };
274    let scopes: Vec<String> = req
275        .scope
276        .split_whitespace()
277        .map(|s| s.to_string())
278        .collect();
279
280    if !submission.allowed() {
281        // Deny → redirect with error=access_denied.
282        let redirect = authz::redirect_with_error(
283            &req.redirect_uri,
284            "access_denied",
285            "user denied consent",
286            req.state.as_deref(),
287        );
288        return Redirect::to(&redirect).into_response();
289    }
290
291    // Persist the consent grant.
292    let grant = ConsentGrant {
293        user_id: session.user_id.clone(),
294        client_id: client.client_id.clone(),
295        scopes: scopes.clone(),
296        granted_at: now_secs(),
297    };
298    if let Err(e) = provider.consents.upsert(&grant).await {
299        return server_error_html(&format!("persist consent: {e}"));
300    }
301
302    issue_authorization_code(&ctx, req, &session.user_id, scopes).await
303}
304
305// =====================================================================
306//   /token
307// =====================================================================
308
309/// `POST /token` — dispatch to `authorization_code` or `refresh_token`.
310/// `client_credentials` is reserved (returns `unsupported_grant_type`).
311pub async fn token_post(
312    State(ctx): State<AuthCtx>,
313    headers: HeaderMap,
314    Form(req): Form<TokenRequest>,
315) -> Response {
316    let _provider = match ctx.oidc_provider.as_ref() {
317        Some(p) => p,
318        None => return server_misconfigured("oidc_provider is not enabled"),
319    };
320
321    // Authenticate the client (basic / post / none-PKCE).
322    let client = match authenticate_client(&ctx, &headers, &req).await {
323        Ok(c) => c,
324        Err((status, body)) => return (status, Json(body)).into_response(),
325    };
326
327    match req.grant_type.as_str() {
328        "authorization_code" => grant_authorization_code(&ctx, &client, &req).await,
329        "refresh_token" => grant_refresh(&ctx, &client, &req).await,
330        other => token_err(
331            StatusCode::BAD_REQUEST,
332            errors::UNSUPPORTED_GRANT_TYPE,
333            Some(format!("grant_type {other:?} is not supported")),
334        ),
335    }
336}
337
338/// Authenticate the client — supports `client_secret_basic`,
339/// `client_secret_post`, or PKCE-only `none`. Returns either the loaded
340/// client row or a wire-shaped error tuple.
341async fn authenticate_client(
342    ctx: &AuthCtx,
343    headers: &HeaderMap,
344    req: &TokenRequest,
345) -> Result<super::types::OidcClient, (StatusCode, TokenErrorBody)> {
346    let provider = ctx.oidc_provider.as_ref().ok_or_else(|| {
347        (
348            StatusCode::INTERNAL_SERVER_ERROR,
349            err_body(errors::SERVER_ERROR, None),
350        )
351    })?;
352
353    // Basic header: "Basic base64(client_id:client_secret)".
354    let basic = headers
355        .get(header::AUTHORIZATION)
356        .and_then(|v| v.to_str().ok())
357        .and_then(|s| s.strip_prefix("Basic "))
358        .or_else(|| {
359            headers
360                .get(header::AUTHORIZATION)
361                .and_then(|v| v.to_str().ok())
362                .and_then(|s| s.strip_prefix("basic "))
363        })
364        .and_then(|enc| data_encoding::BASE64.decode(enc.as_bytes()).ok())
365        .and_then(|bytes| String::from_utf8(bytes).ok())
366        .and_then(|s| {
367            let (id, secret) = s.split_once(':')?;
368            Some((id.to_string(), secret.to_string()))
369        });
370
371    let (client_id, presented_secret) = match (basic, &req.client_id) {
372        (Some((id, secret)), _) => (id, Some(secret)),
373        (None, Some(id)) => (id.clone(), req.client_secret.clone()),
374        (None, None) => {
375            return Err((
376                StatusCode::UNAUTHORIZED,
377                err_body(errors::INVALID_CLIENT, Some("client_id missing".into())),
378            ));
379        }
380    };
381
382    let client = match provider.clients.get(&client_id).await {
383        Ok(Some(c)) => c,
384        _ => {
385            return Err((
386                StatusCode::UNAUTHORIZED,
387                err_body(errors::INVALID_CLIENT, Some("unknown client".into())),
388            ));
389        }
390    };
391
392    match client.token_endpoint_auth_method {
393        super::types::TokenAuthMethod::None => {
394            // Public PKCE-only client — no shared secret.
395            Ok(client)
396        }
397        super::types::TokenAuthMethod::ClientSecretBasic
398        | super::types::TokenAuthMethod::ClientSecretPost => {
399            let presented = presented_secret
400                .as_deref()
401                .map(|s| s.to_string())
402                .unwrap_or_default();
403            let stored = client.client_secret_hash.as_deref().unwrap_or("");
404            if !verify_client_secret(&presented, stored) {
405                return Err((
406                    StatusCode::UNAUTHORIZED,
407                    err_body(errors::INVALID_CLIENT, Some("bad secret".into())),
408                ));
409            }
410            Ok(client)
411        }
412        super::types::TokenAuthMethod::PrivateKeyJwt => {
413            // Reserved for v0.2.0+; reject for now.
414            Err((
415                StatusCode::BAD_REQUEST,
416                err_body(
417                    errors::INVALID_CLIENT,
418                    Some("private_key_jwt not yet supported".into()),
419                ),
420            ))
421        }
422    }
423}
424
425/// Constant-time secret check. The stored hash is either an Argon2 PHC
426/// string (`$argon2id$...`) or — for the simpler v0.2.0 surface — the
427/// plaintext secret. We try Argon2 first and fall back to bytewise
428/// compare so the migration path to PHC-only doesn't require a flag-day.
429fn verify_client_secret(presented: &str, stored: &str) -> bool {
430    if stored.starts_with("$argon2") {
431        let hasher = crate::password::PasswordHasher::default();
432        return hasher.verify(presented, stored).unwrap_or(false);
433    }
434    // Plaintext fallback — constant-time bytewise compare.
435    let a = presented.as_bytes();
436    let b = stored.as_bytes();
437    if a.len() != b.len() {
438        return false;
439    }
440    let mut diff = 0u8;
441    for (x, y) in a.iter().zip(b.iter()) {
442        diff |= x ^ y;
443    }
444    diff == 0
445}
446
447/// `authorization_code` grant — consume the code, verify PKCE, mint
448/// id_token + access_token (+ optionally refresh_token), record the
449/// SSO session row.
450async fn grant_authorization_code(
451    ctx: &AuthCtx,
452    client: &super::types::OidcClient,
453    req: &TokenRequest,
454) -> Response {
455    let provider = match ctx.oidc_provider.as_ref() {
456        Some(p) => p,
457        None => {
458            return token_err(
459                StatusCode::INTERNAL_SERVER_ERROR,
460                errors::SERVER_ERROR,
461                None,
462            );
463        }
464    };
465    let Some(code_str) = req.code.as_deref() else {
466        return token_err(
467            StatusCode::BAD_REQUEST,
468            errors::INVALID_REQUEST,
469            Some("code is required".into()),
470        );
471    };
472    let consumed = match provider.codes.consume(code_str).await {
473        Ok(Some(c)) => c,
474        Ok(None) => {
475            return token_err(
476                StatusCode::BAD_REQUEST,
477                errors::INVALID_GRANT,
478                Some("code is unknown or already used".into()),
479            );
480        }
481        Err(e) => {
482            return token_err(
483                StatusCode::INTERNAL_SERVER_ERROR,
484                errors::SERVER_ERROR,
485                Some(format!("consume code: {e}")),
486            );
487        }
488    };
489    if consumed.expires_at <= now_secs() {
490        return token_err(
491            StatusCode::BAD_REQUEST,
492            errors::INVALID_GRANT,
493            Some("code expired".into()),
494        );
495    }
496    if consumed.client_id != client.client_id {
497        return token_err(
498            StatusCode::BAD_REQUEST,
499            errors::INVALID_GRANT,
500            Some("code does not belong to this client".into()),
501        );
502    }
503    if let Some(redirect) = &req.redirect_uri
504        && redirect != &consumed.redirect_uri
505    {
506        return token_err(
507            StatusCode::BAD_REQUEST,
508            errors::INVALID_GRANT,
509            Some("redirect_uri mismatch".into()),
510        );
511    }
512    // PKCE verify (S256 only — challenge_method always normalised).
513    if !consumed.code_challenge.is_empty() {
514        let verifier = req.code_verifier.as_deref().unwrap_or("");
515        if !tok::verify_pkce_s256(verifier, &consumed.code_challenge) {
516            return token_err(
517                StatusCode::BAD_REQUEST,
518                errors::INVALID_GRANT,
519                Some("PKCE verifier mismatch".into()),
520            );
521        }
522    }
523
524    issue_token_pair(
525        ctx,
526        client,
527        &consumed.user_id,
528        &consumed.scopes,
529        consumed.nonce.as_deref(),
530    )
531    .await
532}
533
534/// `refresh_token` grant — verify, rotate (revoke old, mint new), mint
535/// fresh access + id token. Replay → revoke every refresh token for the
536/// user (OAuth 2.1 replay-detection nuke).
537async fn grant_refresh(
538    ctx: &AuthCtx,
539    client: &super::types::OidcClient,
540    req: &TokenRequest,
541) -> Response {
542    let provider = match ctx.oidc_provider.as_ref() {
543        Some(p) => p,
544        None => {
545            return token_err(
546                StatusCode::INTERNAL_SERVER_ERROR,
547                errors::SERVER_ERROR,
548                None,
549            );
550        }
551    };
552    let Some(presented) = req.refresh_token.as_deref() else {
553        return token_err(
554            StatusCode::BAD_REQUEST,
555            errors::INVALID_REQUEST,
556            Some("refresh_token is required".into()),
557        );
558    };
559    let hash = tok::hash_refresh_token(presented);
560    let row = match provider.refresh.get(&hash).await {
561        Ok(Some(r)) => r,
562        Ok(None) => {
563            return token_err(
564                StatusCode::BAD_REQUEST,
565                errors::INVALID_GRANT,
566                Some("refresh_token unknown".into()),
567            );
568        }
569        Err(e) => {
570            return token_err(
571                StatusCode::INTERNAL_SERVER_ERROR,
572                errors::SERVER_ERROR,
573                Some(format!("refresh lookup: {e}")),
574            );
575        }
576    };
577    if row.revoked {
578        // Replay detected — revoke every token belonging to this user.
579        let _ = provider.refresh.revoke_for_user(&row.user_id).await;
580        return token_err(
581            StatusCode::BAD_REQUEST,
582            errors::INVALID_GRANT,
583            Some("refresh_token revoked (replay detected)".into()),
584        );
585    }
586    if row.expires_at <= now_secs() {
587        return token_err(
588            StatusCode::BAD_REQUEST,
589            errors::INVALID_GRANT,
590            Some("refresh_token expired".into()),
591        );
592    }
593    if row.client_id != client.client_id {
594        return token_err(
595            StatusCode::BAD_REQUEST,
596            errors::INVALID_GRANT,
597            Some("refresh_token client mismatch".into()),
598        );
599    }
600    if let Err(e) = provider.refresh.revoke(&hash).await {
601        return token_err(
602            StatusCode::INTERNAL_SERVER_ERROR,
603            errors::SERVER_ERROR,
604            Some(format!("revoke old refresh: {e}")),
605        );
606    }
607    issue_token_pair(ctx, client, &row.user_id, &row.scopes, None).await
608}
609
610/// Mint id_token + access_token + refresh_token (when `offline_access`
611/// or refresh-token grant in the client's allow-list) and record the
612/// SSO session row. Common path for both `authorization_code` and
613/// `refresh_token` grants.
614async fn issue_token_pair(
615    ctx: &AuthCtx,
616    client: &super::types::OidcClient,
617    user_id: &str,
618    scopes: &[String],
619    nonce: Option<&str>,
620) -> Response {
621    let provider = match ctx.oidc_provider.as_ref() {
622        Some(p) => p,
623        None => {
624            return token_err(
625                StatusCode::INTERNAL_SERVER_ERROR,
626                errors::SERVER_ERROR,
627                None,
628            );
629        }
630    };
631    let user = match ctx.users.get_user_by_id(user_id).await {
632        Ok(Some(u)) => Some(u),
633        _ => None,
634    };
635    let email = user.as_ref().and_then(|u| u.email.clone());
636    let email_verified = user.as_ref().map(|u| u.email_verified).unwrap_or(false);
637    let display_name = user.as_ref().and_then(|u| u.display_name.clone());
638
639    let sid = tok::mint_sid();
640
641    let id_claims = tok::build_id_token_claims(
642        &provider.issuer,
643        user_id,
644        &client.client_id,
645        &sid,
646        scopes,
647        nonce,
648        email.as_deref(),
649        email_verified,
650        display_name.as_deref(),
651    );
652    let access_claims =
653        tok::build_access_token_claims(&provider.issuer, user_id, &client.client_id, &sid, scopes);
654
655    let jwt = match ctx.jwt.as_ref() {
656        Some(j) => j,
657        None => {
658            return token_err(
659                StatusCode::INTERNAL_SERVER_ERROR,
660                errors::SERVER_ERROR,
661                Some("jwt not configured".into()),
662            );
663        }
664    };
665    let id_token = match jwt.issue(&id_claims) {
666        Ok(t) => t,
667        Err(e) => {
668            return token_err(
669                StatusCode::INTERNAL_SERVER_ERROR,
670                errors::SERVER_ERROR,
671                Some(format!("sign id_token: {e}")),
672            );
673        }
674    };
675    let access_token = match jwt.issue(&access_claims) {
676        Ok(t) => t,
677        Err(e) => {
678            return token_err(
679                StatusCode::INTERNAL_SERVER_ERROR,
680                errors::SERVER_ERROR,
681                Some(format!("sign access_token: {e}")),
682            );
683        }
684    };
685
686    // Refresh token issued when the client allows refresh_token grant
687    // OR `offline_access` is in the requested scopes.
688    let issue_refresh =
689        client.allows_grant("refresh_token") || scopes.iter().any(|s| s == "offline_access");
690    let refresh_token = if issue_refresh {
691        let plaintext = tok::mint_refresh_token();
692        let row = tok::build_refresh_row(user_id, &client.client_id, scopes, &plaintext);
693        if let Err(e) = provider.refresh.create(&row).await {
694            return token_err(
695                StatusCode::INTERNAL_SERVER_ERROR,
696                errors::SERVER_ERROR,
697                Some(format!("persist refresh: {e}")),
698            );
699        }
700        Some(plaintext)
701    } else {
702        None
703    };
704
705    // Record SSO session — back-channel logout fans out from this row.
706    let oidc_session = OidcSession {
707        sid: sid.clone(),
708        user_id: user_id.to_string(),
709        client_id: client.client_id.clone(),
710        assay_session_id: None,
711        issued_at: now_secs(),
712        backchannel_logout_uri: client.backchannel_logout_uri.clone(),
713    };
714    if let Err(e) = provider.sessions.create(&oidc_session).await {
715        tracing::warn!(?e, "failed to record SSO session — continuing");
716    }
717
718    let response = TokenResponse {
719        access_token,
720        token_type: "Bearer",
721        expires_in: tok::ACCESS_TOKEN_LIFETIME_SECS as i64,
722        id_token,
723        refresh_token,
724        scope: scopes.join(" "),
725    };
726    (StatusCode::OK, Json(response)).into_response()
727}
728
729// =====================================================================
730//   /userinfo
731// =====================================================================
732
733/// `GET/POST /userinfo` — bearer access_token + scope-filtered claims.
734pub async fn userinfo_get(State(ctx): State<AuthCtx>, headers: HeaderMap) -> Response {
735    let bearer = headers
736        .get(header::AUTHORIZATION)
737        .and_then(|v| v.to_str().ok())
738        .and_then(userinfo::parse_bearer);
739    let Some(token) = bearer else {
740        return (
741            StatusCode::UNAUTHORIZED,
742            Json(json!({"error": "invalid_token"})),
743        )
744            .into_response();
745    };
746    let jwt = match ctx.jwt.as_ref() {
747        Some(j) => j,
748        None => {
749            return (
750                StatusCode::INTERNAL_SERVER_ERROR,
751                Json(json!({"error": "server_error"})),
752            )
753                .into_response();
754        }
755    };
756    let data = match jwt.verify_provider_token::<ProviderAccessTokenClaims>(token) {
757        Ok(d) if d.claims.is_provider_access_token() => d,
758        Err(_) => {
759            return (
760                StatusCode::UNAUTHORIZED,
761                Json(json!({"error": "invalid_token"})),
762            )
763                .into_response();
764        }
765        Ok(_) => {
766            return (
767                StatusCode::UNAUTHORIZED,
768                Json(json!({"error": "invalid_token"})),
769            )
770                .into_response();
771        }
772    };
773    let user = match ctx.users.get_user_by_id(&data.claims.claims.sub).await {
774        Ok(Some(u)) => u,
775        _ => {
776            return (
777                StatusCode::UNAUTHORIZED,
778                Json(json!({"error": "invalid_token"})),
779            )
780                .into_response();
781        }
782    };
783    let claims = userinfo::build_userinfo(&user, &data.claims.claims.scopes());
784    (StatusCode::OK, Json(claims)).into_response()
785}
786
787// =====================================================================
788//   /revoke
789// =====================================================================
790
791/// `POST /revoke` — RFC 7009. Always returns 200 (per spec).
792pub async fn revoke_post(State(ctx): State<AuthCtx>, Form(req): Form<RevokeRequest>) -> Response {
793    if let Some(provider) = ctx.oidc_provider.as_ref() {
794        // Try as a refresh token (the one we can actually revoke).
795        let hash = tok::hash_refresh_token(&req.token);
796        let _ = provider.refresh.revoke(&hash).await;
797    }
798    StatusCode::OK.into_response()
799}
800
801// =====================================================================
802//   /introspect
803// =====================================================================
804
805/// `POST /introspect` — RFC 7662. Validates client auth then returns
806/// `{active, ...}` for known/active tokens or `{active: false}` for
807/// anything else.
808pub async fn introspect_post(
809    State(ctx): State<AuthCtx>,
810    headers: HeaderMap,
811    Form(body): Form<IntrospectRequest>,
812) -> Response {
813    // Require client auth — synthesise a TokenRequest so the existing
814    // helper does the parsing work.
815    let synth = TokenRequest {
816        grant_type: String::new(),
817        ..Default::default()
818    };
819    if authenticate_client(&ctx, &headers, &synth).await.is_err() {
820        return (
821            StatusCode::UNAUTHORIZED,
822            Json(IntrospectResponse::inactive()),
823        )
824            .into_response();
825    }
826
827    let jwt = match ctx.jwt.as_ref() {
828        Some(j) => j,
829        None => return (StatusCode::OK, Json(IntrospectResponse::inactive())).into_response(),
830    };
831
832    // Try as an access_token JWT first.
833    if let Ok(data) = jwt.verify_provider_token::<ProviderAccessTokenClaims>(&body.token)
834        && data.claims.is_provider_access_token()
835    {
836        let claims = data.claims.claims;
837        let resp = IntrospectResponse {
838            active: true,
839            client_id: Some(claims.client_id.clone()),
840            username: Some(claims.sub.clone()),
841            scope: Some(claims.scope.clone()),
842            exp: Some(claims.exp),
843            sub: Some(claims.sub.clone()),
844            aud: Some(claims.aud.clone()),
845            iat: Some(claims.iat),
846            token_type: Some("Bearer".into()),
847        };
848        return (StatusCode::OK, Json(resp)).into_response();
849    }
850
851    // Not a JWT — try as an opaque refresh_token.
852    if let Some(provider) = ctx.oidc_provider.as_ref() {
853        let hash = tok::hash_refresh_token(&body.token);
854        if let Ok(Some(row)) = provider.refresh.get(&hash).await
855            && !row.revoked
856            && row.expires_at > now_secs()
857        {
858            let resp = IntrospectResponse {
859                active: true,
860                client_id: Some(row.client_id.clone()),
861                username: Some(row.user_id.clone()),
862                scope: Some(row.scopes.join(" ")),
863                exp: Some(row.expires_at as i64),
864                sub: Some(row.user_id),
865                aud: Some(row.client_id),
866                iat: Some(row.issued_at as i64),
867                token_type: Some("Bearer".into()),
868            };
869            return (StatusCode::OK, Json(resp)).into_response();
870        }
871    }
872
873    (StatusCode::OK, Json(IntrospectResponse::inactive())).into_response()
874}
875
876// =====================================================================
877//   /logout
878// =====================================================================
879
880/// Logout query params per OIDC RP-Initiated Logout 1.0.
881#[derive(Deserialize)]
882pub struct LogoutQuery {
883    pub id_token_hint: Option<String>,
884    pub post_logout_redirect_uri: Option<String>,
885    pub state: Option<String>,
886}
887
888/// `GET /logout` — revoke the assay session, then redirect (or render).
889pub async fn logout_get(
890    State(ctx): State<AuthCtx>,
891    headers: HeaderMap,
892    Query(q): Query<LogoutQuery>,
893) -> Response {
894    if let Some(sid) = parse_cookie(&headers, crate::session::SESSION_COOKIE) {
895        let _ = ctx.sessions.delete(&sid).await;
896        // Fan out back-channel logout to every SSO session linked to
897        // this assay session (best-effort).
898        if let Some(provider) = ctx.oidc_provider.as_ref() {
899            if let Ok(rows) = provider.sessions.list_by_assay_session(&sid).await {
900                for row in rows {
901                    if let Some(uri) = row.backchannel_logout_uri {
902                        // Fire-and-forget: spawn a task per URI so the
903                        // logout redirect doesn't block on slow clients.
904                        tokio::spawn(async move {
905                            let client = reqwest::Client::new();
906                            let _ = client
907                                .post(&uri)
908                                .form(&[("logout_token", "stub")])
909                                .timeout(std::time::Duration::from_secs(5))
910                                .send()
911                                .await;
912                        });
913                    }
914                }
915            }
916            let _ = provider.sessions.delete_by_assay_session(&sid).await;
917        }
918    }
919    let _ = q.id_token_hint;
920    let _ = q.state;
921    let target = q
922        .post_logout_redirect_uri
923        .unwrap_or_else(|| "/".to_string());
924    let mut response = Redirect::to(&target).into_response();
925    // Clear the session cookie.
926    if let Ok(value) = format!(
927        "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
928        crate::session::SESSION_COOKIE
929    )
930    .parse()
931    {
932        response.headers_mut().append(header::SET_COOKIE, value);
933    }
934    response
935}
936
937// =====================================================================
938//   /oidc/upstream/{slug}/start + /callback
939// =====================================================================
940
941/// Query for the federation start route.
942#[derive(Deserialize)]
943pub struct UpstreamStartQuery {
944    pub return_to: Option<String>,
945}
946
947/// `GET /oidc/upstream/{slug}/start` — kick off federated login.
948pub async fn upstream_start(
949    State(ctx): State<AuthCtx>,
950    Path(slug): Path<String>,
951    Query(q): Query<UpstreamStartQuery>,
952) -> Response {
953    let provider = match ctx.oidc_provider.as_ref() {
954        Some(p) => p,
955        None => return server_misconfigured("oidc_provider is not enabled"),
956    };
957    let registry = match ctx.oidc.as_ref() {
958        Some(r) => r,
959        None => return server_misconfigured("oidc client registry is not enabled"),
960    };
961    let started = match super::federation::start_upstream_login(
962        registry,
963        &provider.upstream_states,
964        &slug,
965        validate_return_to(q.return_to, &provider.public_url),
966    )
967    .await
968    {
969        Ok(s) => s,
970        Err(e) => {
971            return error_html(StatusCode::BAD_REQUEST, &format!("upstream start: {e}"));
972        }
973    };
974    let mut response = Redirect::to(&started.redirect_url).into_response();
975    if let Ok(value) = build_binding_cookie(&started.binding_token, &provider.public_url).parse() {
976        response.headers_mut().append(header::SET_COOKIE, value);
977    }
978    response
979}
980
981/// Query for the federation callback. `iss` is RFC 9207; lenient
982/// (warn-on-missing, reject-on-mismatch).
983#[derive(Deserialize)]
984pub struct UpstreamCallbackQuery {
985    pub code: String,
986    pub state: String,
987    pub iss: Option<String>,
988}
989
990/// `GET /oidc/upstream/{slug}/callback` — finish federated login.
991pub async fn upstream_callback(
992    State(ctx): State<AuthCtx>,
993    headers: HeaderMap,
994    Path(_slug): Path<String>,
995    Query(q): Query<UpstreamCallbackQuery>,
996) -> Response {
997    let provider = match ctx.oidc_provider.as_ref() {
998        Some(p) => p,
999        None => return server_misconfigured("oidc_provider is not enabled"),
1000    };
1001    let registry = match ctx.oidc.as_ref() {
1002        Some(r) => r,
1003        None => return server_misconfigured("oidc client registry is not enabled"),
1004    };
1005    let binding_token = parse_cookie(&headers, UPSTREAM_BINDING_COOKIE);
1006    let info = match super::federation::complete_upstream_login(
1007        registry,
1008        &provider.upstream_states,
1009        &q.code,
1010        &q.state,
1011        binding_token.as_deref(),
1012        q.iss.as_deref(),
1013    )
1014    .await
1015    {
1016        Ok(i) => i,
1017        Err(e) => {
1018            let mut response =
1019                error_html(StatusCode::BAD_REQUEST, &format!("upstream complete: {e}"));
1020            append_clear_binding_cookie(&mut response, &provider.public_url);
1021            return response;
1022        }
1023    };
1024
1025    // Look up or create the local user. Two regimes:
1026    //   auto_provision=true  → first sign-in for an upstream subject
1027    //                          creates an `auth.users` row from the
1028    //                          upstream claims (legacy / open-signup).
1029    //   auto_provision=false → invite-only. Look up by email; if no
1030    //                          row exists, return 403 — operators
1031    //                          pre-populate `auth.users` via the admin
1032    //                          API or the sysops `/auth/users` page.
1033    let user = match ctx
1034        .users
1035        .get_user_by_upstream(&info.provider_slug, &info.subject)
1036        .await
1037    {
1038        Ok(Some(u)) => u,
1039        Ok(None) => {
1040            let existing = if provider.auto_provision {
1041                None
1042            } else {
1043                // Invite-only lookup keyed on email. The upstream MUST
1044                // return one AND mark it verified — an attacker who
1045                // controls an upstream IdP that accepts unverified
1046                // email could otherwise claim an invited address and
1047                // link their upstream subject to the pre-created
1048                // local user.
1049                let email = match info.email.as_deref() {
1050                    Some(e) if !e.is_empty() => e,
1051                    _ => {
1052                        let mut response = error_html(
1053                            StatusCode::FORBIDDEN,
1054                            "upstream did not return an email claim; \
1055                             cannot match against the access list.",
1056                        );
1057                        append_clear_binding_cookie(&mut response, &provider.public_url);
1058                        return response;
1059                    }
1060                };
1061                if !info.email_verified {
1062                    let mut response = error_html(
1063                        StatusCode::FORBIDDEN,
1064                        "upstream returned an unverified email; \
1065                         cannot match against the access list. Verify \
1066                         the address with the upstream provider first.",
1067                    );
1068                    append_clear_binding_cookie(&mut response, &provider.public_url);
1069                    return response;
1070                }
1071                match ctx.users.get_user_by_email(email).await {
1072                    Ok(Some(u)) => Some(u),
1073                    Ok(None) => {
1074                        let mut response = error_html(
1075                            StatusCode::FORBIDDEN,
1076                            &format!(
1077                                "You signed in as {email}, but that account is \
1078                                 not yet authorised for this app. If you believe \
1079                                 this is a mistake, ask an administrator to invite \
1080                                 you."
1081                            ),
1082                        );
1083                        append_clear_binding_cookie(&mut response, &provider.public_url);
1084                        return response;
1085                    }
1086                    Err(e) => {
1087                        let mut response = server_error_html(&format!("user lookup by email: {e}"));
1088                        append_clear_binding_cookie(&mut response, &provider.public_url);
1089                        return response;
1090                    }
1091                }
1092            };
1093            let user = if let Some(u) = existing {
1094                // Invite-only: existing row, just link the upstream.
1095                u
1096            } else {
1097                let id = format!(
1098                    "usr_{}",
1099                    data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<16>())
1100                );
1101                let user = crate::store::User {
1102                    id: id.clone(),
1103                    email: info.email.clone(),
1104                    email_verified: info.email_verified,
1105                    display_name: info.display_name.clone(),
1106                    created_at: now_secs(),
1107                };
1108                if let Err(e) = ctx.users.create_user(&user).await {
1109                    let mut response = server_error_html(&format!("create user: {e}"));
1110                    append_clear_binding_cookie(&mut response, &provider.public_url);
1111                    return response;
1112                }
1113                user
1114            };
1115            if let Err(e) = ctx
1116                .users
1117                .link_upstream(&user.id, &info.provider_slug, &info.subject)
1118                .await
1119            {
1120                let mut response = server_error_html(&format!("link upstream: {e}"));
1121                append_clear_binding_cookie(&mut response, &provider.public_url);
1122                return response;
1123            }
1124            user
1125        }
1126        Err(e) => {
1127            let mut response = server_error_html(&format!("upstream user lookup: {e}"));
1128            append_clear_binding_cookie(&mut response, &provider.public_url);
1129            return response;
1130        }
1131    };
1132
1133    // Mint an assay session.
1134    let mgr = crate::session::SessionManager::with_default_duration(ctx.sessions.clone());
1135    let session = match mgr.create(&user.id).await {
1136        Ok(s) => s,
1137        Err(e) => {
1138            let mut response = server_error_html(&format!("create session: {e}"));
1139            append_clear_binding_cookie(&mut response, &provider.public_url);
1140            return response;
1141        }
1142    };
1143    let mut response = Redirect::to(info.return_to.as_deref().unwrap_or("/")).into_response();
1144    let cookie = crate::session::cookie_for(&session, &provider.public_url);
1145    if let Ok(value) = cookie.to_string().parse() {
1146        response.headers_mut().append(header::SET_COOKIE, value);
1147    }
1148    append_clear_binding_cookie(&mut response, &provider.public_url);
1149    response
1150}
1151
1152/// Build the `Set-Cookie` value for the binding token. Omits `Secure`
1153/// only when the public URL is `http` to a localhost host (dev rigs).
1154/// Cookie `Path` for the binding cookie, derived from the OIDC public
1155/// URL so it matches whatever mount prefix the engine nests the spec
1156/// router under (typically `/auth`). Hardcoding `/oidc/upstream/` left
1157/// the cookie unsent on callbacks when the spec router was nested at
1158/// any prefix other than `/`.
1159fn binding_cookie_path(public_url: &url::Url) -> String {
1160    let base = public_url.path().trim_end_matches('/');
1161    format!("{base}/oidc/upstream/")
1162}
1163
1164fn build_binding_cookie(raw: &str, public_url: &url::Url) -> String {
1165    let secure = !is_plain_http(public_url);
1166    let secure_attr = if secure { "; Secure" } else { "" };
1167    let path = binding_cookie_path(public_url);
1168    format!(
1169        "{UPSTREAM_BINDING_COOKIE}={raw}; Path={path}; HttpOnly; SameSite=Lax; \
1170         Max-Age=300{secure_attr}"
1171    )
1172}
1173
1174/// Append a `Set-Cookie: …; Max-Age=0` header to clear the binding
1175/// cookie. Called on every callback response — the cookie's job ended
1176/// the moment the callback ran.
1177fn append_clear_binding_cookie(response: &mut Response, public_url: &url::Url) {
1178    let path = binding_cookie_path(public_url);
1179    let cleared =
1180        format!("{UPSTREAM_BINDING_COOKIE}=; Path={path}; Max-Age=0; HttpOnly; SameSite=Lax");
1181    if let Ok(value) = cleared.parse() {
1182        response.headers_mut().append(header::SET_COOKIE, value);
1183    }
1184}
1185
1186fn is_plain_http(url: &url::Url) -> bool {
1187    url.scheme() != "https"
1188}
1189
1190/// Validate the `return_to` parameter passed to `/oidc/upstream/{slug}/start`.
1191/// Without this check, an attacker can craft a link to the legitimate
1192/// login surface that, after a successful upstream login, bounces the
1193/// victim to an attacker-controlled URL (open-redirect).
1194///
1195/// Acceptance rules:
1196///   - `None` / empty → `None`
1197///   - Path-only same-origin URLs (`/foo`, `/`) → accepted as-is
1198///   - Absolute URLs whose origin matches `public_url`'s origin → accepted
1199///   - Everything else (protocol-relative `//evil`, cross-origin
1200///     `https://evil`, schemes like `javascript:`) → `None`
1201fn validate_return_to(raw: Option<String>, public_url: &url::Url) -> Option<String> {
1202    let s = raw?;
1203    if s.is_empty() {
1204        return None;
1205    }
1206    // Reject protocol-relative URLs — `//evil/path` resolves to a
1207    // cross-origin redirect when browsers see it as `<current-scheme>://evil/path`.
1208    if s.starts_with("//") {
1209        return None;
1210    }
1211    // Accept same-origin path-only redirects.
1212    if s.starts_with('/') {
1213        return Some(s);
1214    }
1215    // Accept absolute URLs only if their origin matches ours.
1216    match url::Url::parse(&s) {
1217        Ok(u) if u.origin() == public_url.origin() => Some(s),
1218        _ => None,
1219    }
1220}
1221
1222// =====================================================================
1223//   helpers
1224// =====================================================================
1225
1226/// Encode the resume payload — base64-url of the JSON-serialised
1227/// AuthorizeRequest. Symmetric (no signing) — anchored on the
1228/// HttpOnly cookie + the CSRF token check.
1229fn encode_resume(req: &AuthorizeRequest) -> String {
1230    let json = serde_json::to_vec(req).unwrap_or_default();
1231    data_encoding::BASE64URL_NOPAD.encode(&json)
1232}
1233
1234fn decode_resume(s: &str) -> Option<AuthorizeRequest> {
1235    let bytes = data_encoding::BASE64URL_NOPAD.decode(s.as_bytes()).ok()?;
1236    serde_json::from_slice(&bytes).ok()
1237}
1238
1239/// Pull a single cookie value off the `Cookie` request header by name.
1240pub(crate) fn parse_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
1241    let raw = headers.get(header::COOKIE)?.to_str().ok()?;
1242    for kv in raw.split(';') {
1243        let kv = kv.trim();
1244        if let Some((k, v)) = kv.split_once('=')
1245            && k == name
1246        {
1247            return Some(v.to_string());
1248        }
1249    }
1250    None
1251}
1252
1253/// `(StatusCode, Json<TokenErrorBody>)` builder for `/token` errors.
1254fn token_err(status: StatusCode, code: &str, desc: Option<String>) -> Response {
1255    (status, Json(err_body(code, desc))).into_response()
1256}
1257
1258fn err_body(code: &str, desc: Option<String>) -> TokenErrorBody {
1259    TokenErrorBody {
1260        error: code.to_string(),
1261        error_description: desc,
1262    }
1263}
1264
1265/// Render a plain-text error page with the given status. Used for the
1266/// non-redirect-safe authorize errors (bad client_id, bad redirect_uri).
1267fn error_html(status: StatusCode, message: &str) -> Response {
1268    let title = match status {
1269        StatusCode::FORBIDDEN => "Access denied",
1270        StatusCode::UNAUTHORIZED => "Sign-in required",
1271        StatusCode::BAD_REQUEST => "Bad request",
1272        StatusCode::INTERNAL_SERVER_ERROR => "Server error",
1273        _ => "Error",
1274    };
1275    let body = format!(
1276        r#"<!DOCTYPE html>
1277<html lang="en">
1278<head>
1279<meta charset="UTF-8">
1280<meta name="viewport" content="width=device-width, initial-scale=1.0">
1281<title>{title}</title>
1282<style>
1283:root {{
1284  color-scheme: light dark;
1285  --bg: #0d1117; --card: #161b22; --text: #e6edf3; --muted: #8b949e;
1286  --accent: #e6662a; --border: #30363d;
1287}}
1288@media (prefers-color-scheme: light) {{
1289  :root {{
1290    --bg: #f6f8fa; --card: #ffffff; --text: #1f2328; --muted: #59636e;
1291    --accent: #cf5d27; --border: #d0d7de;
1292  }}
1293}}
1294html, body {{ height: 100%; }}
1295body {{
1296  margin: 0;
1297  background: var(--bg);
1298  color: var(--text);
1299  font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
1300  display: flex; align-items: center; justify-content: center;
1301  padding: 1.5rem; box-sizing: border-box;
1302}}
1303.error-card {{
1304  background: var(--card);
1305  border: 1px solid var(--border);
1306  border-radius: 12px;
1307  padding: 2.5rem 2.25rem;
1308  width: 100%; max-width: 420px;
1309  box-sizing: border-box;
1310  box-shadow: 0 8px 24px rgba(0,0,0,.24);
1311  text-align: center;
1312}}
1313h1 {{ margin: 0 0 1rem; font-size: 1.5rem; font-weight: 600; }}
1314p {{ margin: 0 0 1.5rem; color: var(--muted); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }}
1315.actions {{ display: flex; gap: .5rem; justify-content: center; flex-wrap: wrap; }}
1316.button {{
1317  display: inline-block;
1318  padding: .65rem 1.15rem;
1319  border: 1px solid var(--border); border-radius: 8px;
1320  color: var(--text); text-decoration: none;
1321  font-weight: 500; font-size: .95rem;
1322  transition: border-color 120ms, background-color 120ms;
1323}}
1324.button:hover {{ border-color: var(--accent); background: rgba(230,102,42,.06); }}
1325</style>
1326</head>
1327<body>
1328<main class="error-card">
1329<h1>{title}</h1>
1330<p>{message}</p>
1331<div class="actions">
1332  <a class="button" href="/auth/login">Try a different account</a>
1333</div>
1334</main>
1335</body>
1336</html>"#,
1337        title = html_escape_simple(title),
1338        message = html_escape_simple(message),
1339    );
1340    (status, Html(body)).into_response()
1341}
1342
1343/// Minimal HTML-escape for the strings interpolated into `error_html`.
1344/// Sufficient because we only interpolate into element text bodies, not
1345/// into attribute contexts, and the messages are operator-controlled.
1346fn html_escape_simple(s: &str) -> String {
1347    let mut out = String::with_capacity(s.len());
1348    for ch in s.chars() {
1349        match ch {
1350            '<' => out.push_str("&lt;"),
1351            '>' => out.push_str("&gt;"),
1352            '&' => out.push_str("&amp;"),
1353            '"' => out.push_str("&quot;"),
1354            '\'' => out.push_str("&#39;"),
1355            c => out.push(c),
1356        }
1357    }
1358    out
1359}
1360
1361fn server_error_html(message: &str) -> Response {
1362    error_html(StatusCode::INTERNAL_SERVER_ERROR, message)
1363}
1364
1365fn server_misconfigured(reason: &str) -> Response {
1366    error_html(StatusCode::INTERNAL_SERVER_ERROR, reason)
1367}
1368
1369fn now_secs() -> f64 {
1370    SystemTime::now()
1371        .duration_since(UNIX_EPOCH)
1372        .unwrap_or_default()
1373        .as_secs_f64()
1374}
1375
1376/// URL-encode helper — narrow port of [`super::authorize::url_encode`];
1377/// duplicated here because that function is private to the authorize
1378/// module and we'd rather not expand its public surface for a tiny
1379/// helper.
1380fn url_encode(s: &str) -> String {
1381    let mut out = String::with_capacity(s.len());
1382    for byte in s.bytes() {
1383        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
1384            out.push(byte as char);
1385        } else {
1386            out.push_str(&format!("%{:02X}", byte));
1387        }
1388    }
1389    out
1390}
1391
1392fn random_bytes<const N: usize>() -> [u8; N] {
1393    use rand::RngCore;
1394    let mut buf = [0u8; N];
1395    rand::rng().fill_bytes(&mut buf);
1396    buf
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401    use super::*;
1402
1403    #[test]
1404    fn parse_cookie_handles_multi_pair_header() {
1405        let mut headers = HeaderMap::new();
1406        headers.insert(
1407            header::COOKIE,
1408            "assay_session=sess_abc; assay_csrf=csrf_xyz; other=1"
1409                .parse()
1410                .unwrap(),
1411        );
1412        assert_eq!(
1413            parse_cookie(&headers, "assay_session").as_deref(),
1414            Some("sess_abc")
1415        );
1416        assert_eq!(
1417            parse_cookie(&headers, "assay_csrf").as_deref(),
1418            Some("csrf_xyz")
1419        );
1420        assert_eq!(parse_cookie(&headers, "missing"), None);
1421    }
1422
1423    #[test]
1424    fn resume_round_trip() {
1425        let req = AuthorizeRequest {
1426            response_type: "code".into(),
1427            client_id: "c1".into(),
1428            redirect_uri: "https://app/cb".into(),
1429            scope: "openid email".into(),
1430            state: Some("s1".into()),
1431            nonce: None,
1432            code_challenge: Some("ch".into()),
1433            code_challenge_method: Some("S256".into()),
1434            prompt: None,
1435            max_age: None,
1436        };
1437        let encoded = encode_resume(&req);
1438        let decoded = decode_resume(&encoded).unwrap();
1439        assert_eq!(decoded, req);
1440    }
1441
1442    #[test]
1443    fn verify_client_secret_handles_plaintext() {
1444        assert!(verify_client_secret("secret", "secret"));
1445        assert!(!verify_client_secret("wrong", "secret"));
1446        assert!(!verify_client_secret("secret", "differentlength"));
1447    }
1448
1449    #[test]
1450    fn url_encode_handles_reserved_bytes() {
1451        assert_eq!(url_encode("a b/c"), "a%20b%2Fc");
1452        assert_eq!(url_encode("Plain-Text_1.0~"), "Plain-Text_1.0~");
1453    }
1454
1455    fn return_to_issuer() -> url::Url {
1456        url::Url::parse("https://app.example.com/auth").unwrap()
1457    }
1458
1459    #[test]
1460    fn validate_return_to_accepts_relative_path() {
1461        let u = return_to_issuer();
1462        assert_eq!(validate_return_to(Some("/".into()), &u), Some("/".into()));
1463        assert_eq!(
1464            validate_return_to(Some("/dashboard".into()), &u),
1465            Some("/dashboard".into())
1466        );
1467        assert_eq!(
1468            validate_return_to(Some("/a?b=c#d".into()), &u),
1469            Some("/a?b=c#d".into())
1470        );
1471    }
1472
1473    #[test]
1474    fn validate_return_to_accepts_same_origin_absolute() {
1475        let u = return_to_issuer();
1476        let here = "https://app.example.com/some/path".to_string();
1477        assert_eq!(validate_return_to(Some(here.clone()), &u), Some(here));
1478    }
1479
1480    #[test]
1481    fn validate_return_to_rejects_cross_origin() {
1482        let u = return_to_issuer();
1483        assert_eq!(
1484            validate_return_to(Some("https://evil.com".into()), &u),
1485            None
1486        );
1487        assert_eq!(
1488            validate_return_to(Some("https://evil.com/path".into()), &u),
1489            None
1490        );
1491        // Different subdomain — also cross-origin.
1492        assert_eq!(
1493            validate_return_to(Some("https://other.example.com/x".into()), &u),
1494            None
1495        );
1496    }
1497
1498    #[test]
1499    fn validate_return_to_rejects_protocol_relative() {
1500        let u = return_to_issuer();
1501        assert_eq!(validate_return_to(Some("//evil.com/x".into()), &u), None);
1502        assert_eq!(validate_return_to(Some("//evil.com".into()), &u), None);
1503    }
1504
1505    #[test]
1506    fn validate_return_to_rejects_javascript_and_data_schemes() {
1507        let u = return_to_issuer();
1508        assert_eq!(
1509            validate_return_to(Some("javascript:alert(1)".into()), &u),
1510            None
1511        );
1512        assert_eq!(
1513            validate_return_to(Some("data:text/html,<script>".into()), &u),
1514            None
1515        );
1516    }
1517
1518    #[test]
1519    fn validate_return_to_passes_through_none_and_empty() {
1520        let u = return_to_issuer();
1521        assert_eq!(validate_return_to(None, &u), None);
1522        assert_eq!(validate_return_to(Some(String::new()), &u), None);
1523    }
1524}