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::{
29    self as authz, AuthorizeRequest, AuthorizeValidation,
30};
31use super::consent::{ConsentPage, ConsentSubmission, scopes_already_granted};
32use super::introspect::{IntrospectRequest, IntrospectResponse};
33use super::revoke::RevokeRequest;
34use super::token::{
35    self as tok, TokenErrorBody, TokenRequest, TokenResponse, errors,
36};
37use super::types::{ConsentGrant, OidcSession};
38use super::userinfo::{self, AccessTokenClaims};
39
40/// Cookie name carrying a transient "in-flight authorize request"
41/// payload. We base64-url-encode the original querystring so the
42/// consent POST can resume without persisting state.
43const RESUME_COOKIE: &str = "assay_oidc_resume";
44
45// =====================================================================
46//   /authorize
47// =====================================================================
48
49/// `GET /authorize` — orchestrates: validate → check session → render
50/// consent (or skip on prior grant) → mint code → 302 to the consumer.
51pub async fn authorize_get(
52    State(ctx): State<AuthCtx>,
53    headers: HeaderMap,
54    Query(req): Query<AuthorizeRequest>,
55) -> Response {
56    let provider = match ctx.oidc_provider.as_ref() {
57        Some(p) => p,
58        None => return server_misconfigured("oidc_provider is not enabled"),
59    };
60
61    // Look up the registered client.
62    let client = match provider.clients.get(&req.client_id).await {
63        Ok(Some(c)) => c,
64        Ok(None) => {
65            return error_html(
66                StatusCode::BAD_REQUEST,
67                &format!("unknown client_id {:?}", req.client_id),
68            );
69        }
70        Err(e) => return server_error_html(&format!("client lookup failed: {e}")),
71    };
72
73    // Pure validation against the registered client.
74    match authz::validate(&req, &client) {
75        AuthorizeValidation::Ok { scopes } => {
76            authorize_post_validate(ctx.clone(), &headers, req, client, scopes).await
77        }
78        AuthorizeValidation::Fatal { reason } => {
79            error_html(StatusCode::BAD_REQUEST, &reason)
80        }
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.consents.get(&session.user_id, &client.client_id).await {
131            Ok(Some(grant)) => !scopes_already_granted(&scopes, &grant.scopes),
132            _ => true,
133        }
134    };
135
136    if needs_consent {
137        let resume = encode_resume(&req);
138        let page = ConsentPage {
139            client_name: &client.name,
140            issuer: &provider.issuer,
141            scopes: &scopes,
142            csrf_token: &session.csrf_token,
143            resume_token: &resume,
144        };
145        let mut response = Html(page.render_html()).into_response();
146        // Set the resume cookie so the consent POST can rebuild the
147        // authorize request without trusting form payload alone.
148        if let Ok(value) = format!(
149            "{}={}; Path=/; HttpOnly; SameSite=Lax",
150            RESUME_COOKIE, resume
151        )
152        .parse()
153        {
154            response
155                .headers_mut()
156                .append(header::SET_COOKIE, value);
157        }
158        return response;
159    }
160
161    // No consent required — issue the code.
162    issue_authorization_code(&ctx, req, &session.user_id, scopes).await
163}
164
165/// Common path: build + persist an [`AuthorizationCode`] row, then
166/// 302 the user back to the consumer's `redirect_uri`.
167async fn issue_authorization_code(
168    ctx: &AuthCtx,
169    req: AuthorizeRequest,
170    user_id: &str,
171    scopes: Vec<String>,
172) -> Response {
173    let provider = match ctx.oidc_provider.as_ref() {
174        Some(p) => p,
175        None => return server_misconfigured("oidc_provider is not enabled"),
176    };
177    let code = authz::build_code(user_id, &req, scopes);
178    if let Err(e) = provider.codes.create(&code).await {
179        return server_error_html(&format!("persist authorization code: {e}"));
180    }
181    let redirect = authz::redirect_with_code(
182        &req.redirect_uri,
183        &code.code,
184        req.state.as_deref(),
185    );
186    Redirect::to(&redirect).into_response()
187}
188
189/// Reconstruct the original `/authorize` URL so the post-login flow
190/// can resume. We use the OIDC provider's issuer as the base.
191fn rebuild_authorize_url(ctx: &AuthCtx, req: &AuthorizeRequest) -> String {
192    let issuer = ctx
193        .oidc_provider
194        .as_ref()
195        .map(|p| p.issuer.as_str())
196        .unwrap_or("");
197    let mut url = format!("{issuer}/authorize?response_type={}", url_encode(&req.response_type));
198    url.push_str(&format!("&client_id={}", url_encode(&req.client_id)));
199    url.push_str(&format!("&redirect_uri={}", url_encode(&req.redirect_uri)));
200    url.push_str(&format!("&scope={}", url_encode(&req.scope)));
201    if let Some(s) = &req.state {
202        url.push_str(&format!("&state={}", url_encode(s)));
203    }
204    if let Some(n) = &req.nonce {
205        url.push_str(&format!("&nonce={}", url_encode(n)));
206    }
207    if let Some(c) = &req.code_challenge {
208        url.push_str(&format!("&code_challenge={}", url_encode(c)));
209    }
210    if let Some(m) = &req.code_challenge_method {
211        url.push_str(&format!("&code_challenge_method={}", url_encode(m)));
212    }
213    url
214}
215
216// =====================================================================
217//   /authorize/consent
218// =====================================================================
219
220/// `POST /authorize/consent` — user clicked Allow / Deny on the consent
221/// page. We rebuild the original `AuthorizeRequest` from the resume
222/// cookie, persist the consent (on Allow), then issue the code or the
223/// `error=access_denied` redirect.
224pub async fn consent_post(
225    State(ctx): State<AuthCtx>,
226    headers: HeaderMap,
227    Form(submission): Form<ConsentSubmission>,
228) -> Response {
229    let provider = match ctx.oidc_provider.as_ref() {
230        Some(p) => p,
231        None => return server_misconfigured("oidc_provider is not enabled"),
232    };
233
234    // Pull the resume payload from the cookie (defended against form
235    // tampering by anchoring on the cookie, not the form field).
236    let resume = match parse_cookie(&headers, RESUME_COOKIE) {
237        Some(c) => c,
238        None => {
239            return error_html(
240                StatusCode::BAD_REQUEST,
241                "consent flow has no resume token (cookie missing)",
242            );
243        }
244    };
245    let req = match decode_resume(&resume) {
246        Some(r) => r,
247        None => {
248            return error_html(
249                StatusCode::BAD_REQUEST,
250                "consent resume payload is malformed",
251            );
252        }
253    };
254
255    // CSRF: token must match the session's stored csrf_token.
256    let session = match parse_cookie(&headers, crate::session::SESSION_COOKIE) {
257        Some(sid) => ctx.sessions.get(&sid).await.ok().flatten(),
258        None => None,
259    };
260    let Some(session) = session else {
261        return error_html(StatusCode::UNAUTHORIZED, "no active session");
262    };
263    if session.csrf_token != submission.csrf_token {
264        return error_html(StatusCode::FORBIDDEN, "csrf mismatch");
265    }
266
267    // Look up the client + revalidate (it might have been deleted in
268    // the millisecond between authorize_get and now).
269    let client = match provider.clients.get(&req.client_id).await {
270        Ok(Some(c)) => c,
271        _ => return error_html(StatusCode::BAD_REQUEST, "unknown client_id"),
272    };
273    let scopes: Vec<String> = req
274        .scope
275        .split_whitespace()
276        .map(|s| s.to_string())
277        .collect();
278
279    if !submission.allowed() {
280        // Deny → redirect with error=access_denied.
281        let redirect = authz::redirect_with_error(
282            &req.redirect_uri,
283            "access_denied",
284            "user denied consent",
285            req.state.as_deref(),
286        );
287        return Redirect::to(&redirect).into_response();
288    }
289
290    // Persist the consent grant.
291    let grant = ConsentGrant {
292        user_id: session.user_id.clone(),
293        client_id: client.client_id.clone(),
294        scopes: scopes.clone(),
295        granted_at: now_secs(),
296    };
297    if let Err(e) = provider.consents.upsert(&grant).await {
298        return server_error_html(&format!("persist consent: {e}"));
299    }
300
301    issue_authorization_code(&ctx, req, &session.user_id, scopes).await
302}
303
304// =====================================================================
305//   /token
306// =====================================================================
307
308/// `POST /token` — dispatch to `authorization_code` or `refresh_token`.
309/// `client_credentials` is reserved (returns `unsupported_grant_type`).
310pub async fn token_post(
311    State(ctx): State<AuthCtx>,
312    headers: HeaderMap,
313    Form(req): Form<TokenRequest>,
314) -> Response {
315    let _provider = match ctx.oidc_provider.as_ref() {
316        Some(p) => p,
317        None => return server_misconfigured("oidc_provider is not enabled"),
318    };
319
320    // Authenticate the client (basic / post / none-PKCE).
321    let client = match authenticate_client(&ctx, &headers, &req).await {
322        Ok(c) => c,
323        Err((status, body)) => return (status, Json(body)).into_response(),
324    };
325
326    match req.grant_type.as_str() {
327        "authorization_code" => grant_authorization_code(&ctx, &client, &req).await,
328        "refresh_token" => grant_refresh(&ctx, &client, &req).await,
329        other => token_err(
330            StatusCode::BAD_REQUEST,
331            errors::UNSUPPORTED_GRANT_TYPE,
332            Some(format!("grant_type {other:?} is not supported")),
333        ),
334    }
335}
336
337/// Authenticate the client — supports `client_secret_basic`,
338/// `client_secret_post`, or PKCE-only `none`. Returns either the loaded
339/// client row or a wire-shaped error tuple.
340async fn authenticate_client(
341    ctx: &AuthCtx,
342    headers: &HeaderMap,
343    req: &TokenRequest,
344) -> Result<super::types::OidcClient, (StatusCode, TokenErrorBody)> {
345    let provider = ctx
346        .oidc_provider
347        .as_ref()
348        .ok_or_else(|| (StatusCode::INTERNAL_SERVER_ERROR, err_body(errors::SERVER_ERROR, None)))?;
349
350    // Basic header: "Basic base64(client_id:client_secret)".
351    let basic = headers
352        .get(header::AUTHORIZATION)
353        .and_then(|v| v.to_str().ok())
354        .and_then(|s| s.strip_prefix("Basic "))
355        .or_else(|| {
356            headers
357                .get(header::AUTHORIZATION)
358                .and_then(|v| v.to_str().ok())
359                .and_then(|s| s.strip_prefix("basic "))
360        })
361        .and_then(|enc| data_encoding::BASE64.decode(enc.as_bytes()).ok())
362        .and_then(|bytes| String::from_utf8(bytes).ok())
363        .and_then(|s| {
364            let (id, secret) = s.split_once(':')?;
365            Some((id.to_string(), secret.to_string()))
366        });
367
368    let (client_id, presented_secret) = match (basic, &req.client_id) {
369        (Some((id, secret)), _) => (id, Some(secret)),
370        (None, Some(id)) => (id.clone(), req.client_secret.clone()),
371        (None, None) => {
372            return Err((
373                StatusCode::UNAUTHORIZED,
374                err_body(errors::INVALID_CLIENT, Some("client_id missing".into())),
375            ));
376        }
377    };
378
379    let client = match provider.clients.get(&client_id).await {
380        Ok(Some(c)) => c,
381        _ => {
382            return Err((
383                StatusCode::UNAUTHORIZED,
384                err_body(errors::INVALID_CLIENT, Some("unknown client".into())),
385            ));
386        }
387    };
388
389    match client.token_endpoint_auth_method {
390        super::types::TokenAuthMethod::None => {
391            // Public PKCE-only client — no shared secret.
392            Ok(client)
393        }
394        super::types::TokenAuthMethod::ClientSecretBasic
395        | super::types::TokenAuthMethod::ClientSecretPost => {
396            let presented = presented_secret
397                .as_deref()
398                .map(|s| s.to_string())
399                .unwrap_or_default();
400            let stored = client.client_secret_hash.as_deref().unwrap_or("");
401            if !verify_client_secret(&presented, stored) {
402                return Err((
403                    StatusCode::UNAUTHORIZED,
404                    err_body(errors::INVALID_CLIENT, Some("bad secret".into())),
405                ));
406            }
407            Ok(client)
408        }
409        super::types::TokenAuthMethod::PrivateKeyJwt => {
410            // Reserved for v0.2.0+; reject for now.
411            Err((
412                StatusCode::BAD_REQUEST,
413                err_body(
414                    errors::INVALID_CLIENT,
415                    Some("private_key_jwt not yet supported".into()),
416                ),
417            ))
418        }
419    }
420}
421
422/// Constant-time secret check. The stored hash is either an Argon2 PHC
423/// string (`$argon2id$...`) or — for the simpler v0.2.0 surface — the
424/// plaintext secret. We try Argon2 first and fall back to bytewise
425/// compare so the migration path to PHC-only doesn't require a flag-day.
426fn verify_client_secret(presented: &str, stored: &str) -> bool {
427    if stored.starts_with("$argon2") {
428        let hasher = crate::password::PasswordHasher::default();
429        return hasher.verify(presented, stored).unwrap_or(false);
430    }
431    // Plaintext fallback — constant-time bytewise compare.
432    let a = presented.as_bytes();
433    let b = stored.as_bytes();
434    if a.len() != b.len() {
435        return false;
436    }
437    let mut diff = 0u8;
438    for (x, y) in a.iter().zip(b.iter()) {
439        diff |= x ^ y;
440    }
441    diff == 0
442}
443
444/// `authorization_code` grant — consume the code, verify PKCE, mint
445/// id_token + access_token (+ optionally refresh_token), record the
446/// SSO session row.
447async fn grant_authorization_code(
448    ctx: &AuthCtx,
449    client: &super::types::OidcClient,
450    req: &TokenRequest,
451) -> Response {
452    let provider = match ctx.oidc_provider.as_ref() {
453        Some(p) => p,
454        None => return token_err(StatusCode::INTERNAL_SERVER_ERROR, errors::SERVER_ERROR, None),
455    };
456    let Some(code_str) = req.code.as_deref() else {
457        return token_err(
458            StatusCode::BAD_REQUEST,
459            errors::INVALID_REQUEST,
460            Some("code is required".into()),
461        );
462    };
463    let consumed = match provider.codes.consume(code_str).await {
464        Ok(Some(c)) => c,
465        Ok(None) => {
466            return token_err(
467                StatusCode::BAD_REQUEST,
468                errors::INVALID_GRANT,
469                Some("code is unknown or already used".into()),
470            );
471        }
472        Err(e) => {
473            return token_err(
474                StatusCode::INTERNAL_SERVER_ERROR,
475                errors::SERVER_ERROR,
476                Some(format!("consume code: {e}")),
477            );
478        }
479    };
480    if consumed.expires_at <= now_secs() {
481        return token_err(
482            StatusCode::BAD_REQUEST,
483            errors::INVALID_GRANT,
484            Some("code expired".into()),
485        );
486    }
487    if consumed.client_id != client.client_id {
488        return token_err(
489            StatusCode::BAD_REQUEST,
490            errors::INVALID_GRANT,
491            Some("code does not belong to this client".into()),
492        );
493    }
494    if let Some(redirect) = &req.redirect_uri
495        && redirect != &consumed.redirect_uri {
496            return token_err(
497                StatusCode::BAD_REQUEST,
498                errors::INVALID_GRANT,
499                Some("redirect_uri mismatch".into()),
500            );
501        }
502    // PKCE verify (S256 only — challenge_method always normalised).
503    if !consumed.code_challenge.is_empty() {
504        let verifier = req.code_verifier.as_deref().unwrap_or("");
505        if !tok::verify_pkce_s256(verifier, &consumed.code_challenge) {
506            return token_err(
507                StatusCode::BAD_REQUEST,
508                errors::INVALID_GRANT,
509                Some("PKCE verifier mismatch".into()),
510            );
511        }
512    }
513
514    issue_token_pair(
515        ctx,
516        client,
517        &consumed.user_id,
518        &consumed.scopes,
519        consumed.nonce.as_deref(),
520    )
521    .await
522}
523
524/// `refresh_token` grant — verify, rotate (revoke old, mint new), mint
525/// fresh access + id token. Replay → revoke every refresh token for the
526/// user (OAuth 2.1 replay-detection nuke).
527async fn grant_refresh(
528    ctx: &AuthCtx,
529    client: &super::types::OidcClient,
530    req: &TokenRequest,
531) -> Response {
532    let provider = match ctx.oidc_provider.as_ref() {
533        Some(p) => p,
534        None => return token_err(StatusCode::INTERNAL_SERVER_ERROR, errors::SERVER_ERROR, None),
535    };
536    let Some(presented) = req.refresh_token.as_deref() else {
537        return token_err(
538            StatusCode::BAD_REQUEST,
539            errors::INVALID_REQUEST,
540            Some("refresh_token is required".into()),
541        );
542    };
543    let hash = tok::hash_refresh_token(presented);
544    let row = match provider.refresh.get(&hash).await {
545        Ok(Some(r)) => r,
546        Ok(None) => {
547            return token_err(
548                StatusCode::BAD_REQUEST,
549                errors::INVALID_GRANT,
550                Some("refresh_token unknown".into()),
551            );
552        }
553        Err(e) => {
554            return token_err(
555                StatusCode::INTERNAL_SERVER_ERROR,
556                errors::SERVER_ERROR,
557                Some(format!("refresh lookup: {e}")),
558            );
559        }
560    };
561    if row.revoked {
562        // Replay detected — revoke every token belonging to this user.
563        let _ = provider.refresh.revoke_for_user(&row.user_id).await;
564        return token_err(
565            StatusCode::BAD_REQUEST,
566            errors::INVALID_GRANT,
567            Some("refresh_token revoked (replay detected)".into()),
568        );
569    }
570    if row.expires_at <= now_secs() {
571        return token_err(
572            StatusCode::BAD_REQUEST,
573            errors::INVALID_GRANT,
574            Some("refresh_token expired".into()),
575        );
576    }
577    if row.client_id != client.client_id {
578        return token_err(
579            StatusCode::BAD_REQUEST,
580            errors::INVALID_GRANT,
581            Some("refresh_token client mismatch".into()),
582        );
583    }
584    if let Err(e) = provider.refresh.revoke(&hash).await {
585        return token_err(
586            StatusCode::INTERNAL_SERVER_ERROR,
587            errors::SERVER_ERROR,
588            Some(format!("revoke old refresh: {e}")),
589        );
590    }
591    issue_token_pair(ctx, client, &row.user_id, &row.scopes, None).await
592}
593
594/// Mint id_token + access_token + refresh_token (when `offline_access`
595/// or refresh-token grant in the client's allow-list) and record the
596/// SSO session row. Common path for both `authorization_code` and
597/// `refresh_token` grants.
598async fn issue_token_pair(
599    ctx: &AuthCtx,
600    client: &super::types::OidcClient,
601    user_id: &str,
602    scopes: &[String],
603    nonce: Option<&str>,
604) -> Response {
605    let provider = match ctx.oidc_provider.as_ref() {
606        Some(p) => p,
607        None => return token_err(StatusCode::INTERNAL_SERVER_ERROR, errors::SERVER_ERROR, None),
608    };
609    let user = match ctx.users.get_user_by_id(user_id).await {
610        Ok(Some(u)) => Some(u),
611        _ => None,
612    };
613    let email = user.as_ref().and_then(|u| u.email.clone());
614    let email_verified = user.as_ref().map(|u| u.email_verified).unwrap_or(false);
615    let display_name = user.as_ref().and_then(|u| u.display_name.clone());
616
617    let sid = tok::mint_sid();
618
619    let id_claims = tok::build_id_token_claims(
620        &provider.issuer,
621        user_id,
622        &client.client_id,
623        &sid,
624        scopes,
625        nonce,
626        email.as_deref(),
627        email_verified,
628        display_name.as_deref(),
629    );
630    let access_claims =
631        tok::build_access_token_claims(&provider.issuer, user_id, &client.client_id, &sid, scopes);
632
633    let jwt = match ctx.jwt.as_ref() {
634        Some(j) => j,
635        None => {
636            return token_err(
637                StatusCode::INTERNAL_SERVER_ERROR,
638                errors::SERVER_ERROR,
639                Some("jwt not configured".into()),
640            );
641        }
642    };
643    let id_token = match jwt.issue(&id_claims) {
644        Ok(t) => t,
645        Err(e) => {
646            return token_err(
647                StatusCode::INTERNAL_SERVER_ERROR,
648                errors::SERVER_ERROR,
649                Some(format!("sign id_token: {e}")),
650            );
651        }
652    };
653    let access_token = match jwt.issue(&access_claims) {
654        Ok(t) => t,
655        Err(e) => {
656            return token_err(
657                StatusCode::INTERNAL_SERVER_ERROR,
658                errors::SERVER_ERROR,
659                Some(format!("sign access_token: {e}")),
660            );
661        }
662    };
663
664    // Refresh token issued when the client allows refresh_token grant
665    // OR `offline_access` is in the requested scopes.
666    let issue_refresh = client.allows_grant("refresh_token")
667        || scopes.iter().any(|s| s == "offline_access");
668    let refresh_token = if issue_refresh {
669        let plaintext = tok::mint_refresh_token();
670        let row = tok::build_refresh_row(user_id, &client.client_id, scopes, &plaintext);
671        if let Err(e) = provider.refresh.create(&row).await {
672            return token_err(
673                StatusCode::INTERNAL_SERVER_ERROR,
674                errors::SERVER_ERROR,
675                Some(format!("persist refresh: {e}")),
676            );
677        }
678        Some(plaintext)
679    } else {
680        None
681    };
682
683    // Record SSO session — back-channel logout fans out from this row.
684    let oidc_session = OidcSession {
685        sid: sid.clone(),
686        user_id: user_id.to_string(),
687        client_id: client.client_id.clone(),
688        assay_session_id: None,
689        issued_at: now_secs(),
690        backchannel_logout_uri: client.backchannel_logout_uri.clone(),
691    };
692    if let Err(e) = provider.sessions.create(&oidc_session).await {
693        tracing::warn!(?e, "failed to record SSO session — continuing");
694    }
695
696    let response = TokenResponse {
697        access_token,
698        token_type: "Bearer",
699        expires_in: tok::ACCESS_TOKEN_LIFETIME_SECS as i64,
700        id_token,
701        refresh_token,
702        scope: scopes.join(" "),
703    };
704    (StatusCode::OK, Json(response)).into_response()
705}
706
707// =====================================================================
708//   /userinfo
709// =====================================================================
710
711/// `GET/POST /userinfo` — bearer access_token + scope-filtered claims.
712pub async fn userinfo_get(State(ctx): State<AuthCtx>, headers: HeaderMap) -> Response {
713    let bearer = headers
714        .get(header::AUTHORIZATION)
715        .and_then(|v| v.to_str().ok())
716        .and_then(userinfo::parse_bearer);
717    let Some(token) = bearer else {
718        return (StatusCode::UNAUTHORIZED, Json(json!({"error": "invalid_token"})))
719            .into_response();
720    };
721    let jwt = match ctx.jwt.as_ref() {
722        Some(j) => j,
723        None => {
724            return (
725                StatusCode::INTERNAL_SERVER_ERROR,
726                Json(json!({"error": "server_error"})),
727            )
728                .into_response();
729        }
730    };
731    let data = match jwt.verify::<AccessTokenClaims>(token) {
732        Ok(d) => d,
733        Err(_) => {
734            return (StatusCode::UNAUTHORIZED, Json(json!({"error": "invalid_token"})))
735                .into_response();
736        }
737    };
738    let user = match ctx.users.get_user_by_id(&data.claims.sub).await {
739        Ok(Some(u)) => u,
740        _ => {
741            return (StatusCode::UNAUTHORIZED, Json(json!({"error": "invalid_token"})))
742                .into_response();
743        }
744    };
745    let claims = userinfo::build_userinfo(&user, &data.claims.scopes());
746    (StatusCode::OK, Json(claims)).into_response()
747}
748
749// =====================================================================
750//   /revoke
751// =====================================================================
752
753/// `POST /revoke` — RFC 7009. Always returns 200 (per spec).
754pub async fn revoke_post(
755    State(ctx): State<AuthCtx>,
756    Form(req): Form<RevokeRequest>,
757) -> Response {
758    if let Some(provider) = ctx.oidc_provider.as_ref() {
759        // Try as a refresh token (the one we can actually revoke).
760        let hash = tok::hash_refresh_token(&req.token);
761        let _ = provider.refresh.revoke(&hash).await;
762    }
763    StatusCode::OK.into_response()
764}
765
766// =====================================================================
767//   /introspect
768// =====================================================================
769
770/// `POST /introspect` — RFC 7662. Validates client auth then returns
771/// `{active, ...}` for known/active tokens or `{active: false}` for
772/// anything else.
773pub async fn introspect_post(
774    State(ctx): State<AuthCtx>,
775    headers: HeaderMap,
776    Form(body): Form<IntrospectRequest>,
777) -> Response {
778    // Require client auth — synthesise a TokenRequest so the existing
779    // helper does the parsing work.
780    let synth = TokenRequest {
781        grant_type: String::new(),
782        ..Default::default()
783    };
784    if authenticate_client(&ctx, &headers, &synth).await.is_err() {
785        return (StatusCode::UNAUTHORIZED, Json(IntrospectResponse::inactive()))
786            .into_response();
787    }
788
789    let jwt = match ctx.jwt.as_ref() {
790        Some(j) => j,
791        None => return (StatusCode::OK, Json(IntrospectResponse::inactive())).into_response(),
792    };
793
794    // Try as an access_token JWT first.
795    if let Ok(data) = jwt.verify::<AccessTokenClaims>(&body.token) {
796        let resp = IntrospectResponse {
797            active: true,
798            client_id: Some(data.claims.client_id.clone()),
799            username: Some(data.claims.sub.clone()),
800            scope: Some(data.claims.scope.clone()),
801            exp: Some(data.claims.exp),
802            sub: Some(data.claims.sub.clone()),
803            aud: Some(data.claims.aud.clone()),
804            iat: Some(data.claims.iat),
805            token_type: Some("Bearer".into()),
806        };
807        return (StatusCode::OK, Json(resp)).into_response();
808    }
809
810    // Not a JWT — try as an opaque refresh_token.
811    if let Some(provider) = ctx.oidc_provider.as_ref() {
812        let hash = tok::hash_refresh_token(&body.token);
813        if let Ok(Some(row)) = provider.refresh.get(&hash).await
814            && !row.revoked && row.expires_at > now_secs() {
815                let resp = IntrospectResponse {
816                    active: true,
817                    client_id: Some(row.client_id.clone()),
818                    username: Some(row.user_id.clone()),
819                    scope: Some(row.scopes.join(" ")),
820                    exp: Some(row.expires_at as i64),
821                    sub: Some(row.user_id),
822                    aud: Some(row.client_id),
823                    iat: Some(row.issued_at as i64),
824                    token_type: Some("Bearer".into()),
825                };
826                return (StatusCode::OK, Json(resp)).into_response();
827            }
828    }
829
830    (StatusCode::OK, Json(IntrospectResponse::inactive())).into_response()
831}
832
833// =====================================================================
834//   /logout
835// =====================================================================
836
837/// Logout query params per OIDC RP-Initiated Logout 1.0.
838#[derive(Deserialize)]
839pub struct LogoutQuery {
840    pub id_token_hint: Option<String>,
841    pub post_logout_redirect_uri: Option<String>,
842    pub state: Option<String>,
843}
844
845/// `GET /logout` — revoke the assay session, then redirect (or render).
846pub async fn logout_get(
847    State(ctx): State<AuthCtx>,
848    headers: HeaderMap,
849    Query(q): Query<LogoutQuery>,
850) -> Response {
851    if let Some(sid) = parse_cookie(&headers, crate::session::SESSION_COOKIE) {
852        let _ = ctx.sessions.delete(&sid).await;
853        // Fan out back-channel logout to every SSO session linked to
854        // this assay session (best-effort).
855        if let Some(provider) = ctx.oidc_provider.as_ref() {
856            if let Ok(rows) = provider.sessions.list_by_assay_session(&sid).await {
857                for row in rows {
858                    if let Some(uri) = row.backchannel_logout_uri {
859                        // Fire-and-forget: spawn a task per URI so the
860                        // logout redirect doesn't block on slow clients.
861                        tokio::spawn(async move {
862                            let client = reqwest::Client::new();
863                            let _ = client
864                                .post(&uri)
865                                .form(&[("logout_token", "stub")])
866                                .timeout(std::time::Duration::from_secs(5))
867                                .send()
868                                .await;
869                        });
870                    }
871                }
872            }
873            let _ = provider.sessions.delete_by_assay_session(&sid).await;
874        }
875    }
876    let _ = q.id_token_hint;
877    let _ = q.state;
878    let target = q.post_logout_redirect_uri.unwrap_or_else(|| "/".to_string());
879    let mut response = Redirect::to(&target).into_response();
880    // Clear the session cookie.
881    if let Ok(value) = format!(
882        "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
883        crate::session::SESSION_COOKIE
884    )
885    .parse()
886    {
887        response.headers_mut().append(header::SET_COOKIE, value);
888    }
889    response
890}
891
892// =====================================================================
893//   /oidc/upstream/{slug}/start + /callback
894// =====================================================================
895
896/// Query for the federation start route.
897#[derive(Deserialize)]
898pub struct UpstreamStartQuery {
899    pub return_to: Option<String>,
900}
901
902/// `GET /oidc/upstream/{slug}/start` — kick off federated login.
903pub async fn upstream_start(
904    State(ctx): State<AuthCtx>,
905    Path(slug): Path<String>,
906    Query(q): Query<UpstreamStartQuery>,
907) -> Response {
908    let provider = match ctx.oidc_provider.as_ref() {
909        Some(p) => p,
910        None => return server_misconfigured("oidc_provider is not enabled"),
911    };
912    let registry = match ctx.oidc.as_ref() {
913        Some(r) => r,
914        None => return server_misconfigured("oidc client registry is not enabled"),
915    };
916    let started = match super::federation::start_upstream_login(
917        registry,
918        &provider.upstream_states,
919        &slug,
920        q.return_to,
921    )
922    .await
923    {
924        Ok(s) => s,
925        Err(e) => {
926            return error_html(StatusCode::BAD_REQUEST, &format!("upstream start: {e}"));
927        }
928    };
929    Redirect::to(&started.redirect_url).into_response()
930}
931
932/// Query for the federation callback.
933#[derive(Deserialize)]
934pub struct UpstreamCallbackQuery {
935    pub code: String,
936    pub state: String,
937}
938
939/// `GET /oidc/upstream/{slug}/callback` — finish federated login.
940pub async fn upstream_callback(
941    State(ctx): State<AuthCtx>,
942    Path(_slug): Path<String>,
943    Query(q): Query<UpstreamCallbackQuery>,
944) -> Response {
945    let provider = match ctx.oidc_provider.as_ref() {
946        Some(p) => p,
947        None => return server_misconfigured("oidc_provider is not enabled"),
948    };
949    let registry = match ctx.oidc.as_ref() {
950        Some(r) => r,
951        None => return server_misconfigured("oidc client registry is not enabled"),
952    };
953    let info = match super::federation::complete_upstream_login(
954        registry,
955        &provider.upstream_states,
956        &q.code,
957        &q.state,
958    )
959    .await
960    {
961        Ok(i) => i,
962        Err(e) => {
963            return error_html(StatusCode::BAD_REQUEST, &format!("upstream complete: {e}"));
964        }
965    };
966
967    // Look up or create the local user.
968    let user = match ctx
969        .users
970        .get_user_by_upstream(&info.provider_slug, &info.subject)
971        .await
972    {
973        Ok(Some(u)) => u,
974        Ok(None) => {
975            // First time we've seen this upstream subject — create the
976            // user + the link.
977            let id = format!(
978                "usr_{}",
979                data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<16>())
980            );
981            let user = crate::store::User {
982                id: id.clone(),
983                email: info.email.clone(),
984                email_verified: info.email_verified,
985                display_name: info.display_name.clone(),
986                created_at: now_secs(),
987            };
988            if let Err(e) = ctx.users.create_user(&user).await {
989                return server_error_html(&format!("create user: {e}"));
990            }
991            if let Err(e) = ctx
992                .users
993                .link_upstream(&id, &info.provider_slug, &info.subject)
994                .await
995            {
996                return server_error_html(&format!("link upstream: {e}"));
997            }
998            user
999        }
1000        Err(e) => return server_error_html(&format!("upstream user lookup: {e}")),
1001    };
1002
1003    // Mint an assay session.
1004    let mgr = crate::session::SessionManager::with_default_duration(ctx.sessions.clone());
1005    let session = match mgr.create(&user.id).await {
1006        Ok(s) => s,
1007        Err(e) => return server_error_html(&format!("create session: {e}")),
1008    };
1009    let mut response = Redirect::to(info.return_to.as_deref().unwrap_or("/")).into_response();
1010    let cookie = crate::session::cookie_for(&session, &provider.public_url);
1011    if let Ok(value) = cookie.to_string().parse() {
1012        response.headers_mut().append(header::SET_COOKIE, value);
1013    }
1014    response
1015}
1016
1017// =====================================================================
1018//   helpers
1019// =====================================================================
1020
1021/// Encode the resume payload — base64-url of the JSON-serialised
1022/// AuthorizeRequest. Symmetric (no signing) — anchored on the
1023/// HttpOnly cookie + the CSRF token check.
1024fn encode_resume(req: &AuthorizeRequest) -> String {
1025    let json = serde_json::to_vec(req).unwrap_or_default();
1026    data_encoding::BASE64URL_NOPAD.encode(&json)
1027}
1028
1029fn decode_resume(s: &str) -> Option<AuthorizeRequest> {
1030    let bytes = data_encoding::BASE64URL_NOPAD.decode(s.as_bytes()).ok()?;
1031    serde_json::from_slice(&bytes).ok()
1032}
1033
1034/// Pull a single cookie value off the `Cookie` request header by name.
1035pub(crate) fn parse_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
1036    let raw = headers.get(header::COOKIE)?.to_str().ok()?;
1037    for kv in raw.split(';') {
1038        let kv = kv.trim();
1039        if let Some((k, v)) = kv.split_once('=')
1040            && k == name {
1041                return Some(v.to_string());
1042            }
1043    }
1044    None
1045}
1046
1047/// `(StatusCode, Json<TokenErrorBody>)` builder for `/token` errors.
1048fn token_err(status: StatusCode, code: &str, desc: Option<String>) -> Response {
1049    (status, Json(err_body(code, desc))).into_response()
1050}
1051
1052fn err_body(code: &str, desc: Option<String>) -> TokenErrorBody {
1053    TokenErrorBody {
1054        error: code.to_string(),
1055        error_description: desc,
1056    }
1057}
1058
1059/// Render a plain-text error page with the given status. Used for the
1060/// non-redirect-safe authorize errors (bad client_id, bad redirect_uri).
1061fn error_html(status: StatusCode, message: &str) -> Response {
1062    let body = format!("<!doctype html><body><h1>Error</h1><pre>{message}</pre></body>");
1063    (status, Html(body)).into_response()
1064}
1065
1066fn server_error_html(message: &str) -> Response {
1067    error_html(StatusCode::INTERNAL_SERVER_ERROR, message)
1068}
1069
1070fn server_misconfigured(reason: &str) -> Response {
1071    error_html(StatusCode::INTERNAL_SERVER_ERROR, reason)
1072}
1073
1074fn now_secs() -> f64 {
1075    SystemTime::now()
1076        .duration_since(UNIX_EPOCH)
1077        .unwrap_or_default()
1078        .as_secs_f64()
1079}
1080
1081/// URL-encode helper — narrow port of [`super::authorize::url_encode`];
1082/// duplicated here because that function is private to the authorize
1083/// module and we'd rather not expand its public surface for a tiny
1084/// helper.
1085fn url_encode(s: &str) -> String {
1086    let mut out = String::with_capacity(s.len());
1087    for byte in s.bytes() {
1088        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
1089            out.push(byte as char);
1090        } else {
1091            out.push_str(&format!("%{:02X}", byte));
1092        }
1093    }
1094    out
1095}
1096
1097fn random_bytes<const N: usize>() -> [u8; N] {
1098    use rand::RngCore;
1099    let mut buf = [0u8; N];
1100    rand::rng().fill_bytes(&mut buf);
1101    buf
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107
1108    #[test]
1109    fn parse_cookie_handles_multi_pair_header() {
1110        let mut headers = HeaderMap::new();
1111        headers.insert(
1112            header::COOKIE,
1113            "assay_session=sess_abc; assay_csrf=csrf_xyz; other=1"
1114                .parse()
1115                .unwrap(),
1116        );
1117        assert_eq!(
1118            parse_cookie(&headers, "assay_session").as_deref(),
1119            Some("sess_abc")
1120        );
1121        assert_eq!(
1122            parse_cookie(&headers, "assay_csrf").as_deref(),
1123            Some("csrf_xyz")
1124        );
1125        assert_eq!(parse_cookie(&headers, "missing"), None);
1126    }
1127
1128    #[test]
1129    fn resume_round_trip() {
1130        let req = AuthorizeRequest {
1131            response_type: "code".into(),
1132            client_id: "c1".into(),
1133            redirect_uri: "https://app/cb".into(),
1134            scope: "openid email".into(),
1135            state: Some("s1".into()),
1136            nonce: None,
1137            code_challenge: Some("ch".into()),
1138            code_challenge_method: Some("S256".into()),
1139            prompt: None,
1140            max_age: None,
1141        };
1142        let encoded = encode_resume(&req);
1143        let decoded = decode_resume(&encoded).unwrap();
1144        assert_eq!(decoded, req);
1145    }
1146
1147    #[test]
1148    fn verify_client_secret_handles_plaintext() {
1149        assert!(verify_client_secret("secret", "secret"));
1150        assert!(!verify_client_secret("wrong", "secret"));
1151        assert!(!verify_client_secret("secret", "differentlength"));
1152    }
1153
1154    #[test]
1155    fn url_encode_handles_reserved_bytes() {
1156        assert_eq!(url_encode("a b/c"), "a%20b%2Fc");
1157        assert_eq!(url_encode("Plain-Text_1.0~"), "Plain-Text_1.0~");
1158    }
1159}