Skip to main content

riley_auth_api/routes/
oauth_provider.rs

1use std::collections::BTreeSet;
2
3use axum::extract::{Query, State};
4use axum::http::{HeaderMap, StatusCode};
5use axum::response::{IntoResponse, Response};
6use axum::routing::{get, post};
7use axum::{Form, Json, Router};
8use axum_extra::extract::CookieJar;
9use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
10use chrono::{Duration, Utc};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use subtle::ConstantTimeEq;
14
15use riley_auth_core::db;
16use riley_auth_core::error::{Error, ErrorBody, www_authenticate_value};
17use riley_auth_core::jwt;
18
19use crate::server::AppState;
20
21/// OIDC protocol-level scopes that are always accepted without config definition
22/// or client allowed_scopes check. These scopes control standard OIDC claim sets.
23const PROTOCOL_SCOPES: &[&str] = &["openid", "profile", "email"];
24
25// --- Request/Response types ---
26
27#[derive(Deserialize)]
28pub struct AuthorizeQuery {
29    client_id: String,
30    redirect_uri: String,
31    response_type: String,
32    scope: Option<String>,
33    state: Option<String>,
34    code_challenge: Option<String>,
35    code_challenge_method: Option<String>,
36    nonce: Option<String>,
37    prompt: Option<String>,
38}
39
40#[derive(Deserialize, utoipa::ToSchema)]
41pub struct TokenRequest {
42    grant_type: String,
43    code: Option<String>,
44    redirect_uri: Option<String>,
45    client_id: Option<String>,
46    client_secret: Option<String>,
47    code_verifier: Option<String>,
48    refresh_token: Option<String>,
49    scope: Option<String>,
50}
51
52#[derive(Serialize, utoipa::ToSchema)]
53pub struct TokenResponse {
54    access_token: String,
55    token_type: &'static str,
56    expires_in: u64,
57    refresh_token: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    id_token: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    scope: Option<String>,
62}
63
64#[derive(Deserialize)]
65pub struct ConsentQuery {
66    consent_id: uuid::Uuid,
67}
68
69#[derive(Serialize, utoipa::ToSchema)]
70pub struct ConsentResponse {
71    client: ConsentClient,
72    scopes: Vec<ConsentScope>,
73    redirect_uri: String,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    state: Option<String>,
76    expires_at: String,
77}
78
79#[derive(Serialize, utoipa::ToSchema)]
80pub struct ConsentClient {
81    name: String,
82    client_id: String,
83}
84
85#[derive(Serialize, utoipa::ToSchema)]
86pub struct ConsentScope {
87    name: String,
88    description: String,
89}
90
91#[derive(Deserialize, utoipa::ToSchema)]
92pub struct ConsentDecision {
93    approved: bool,
94}
95
96#[derive(Deserialize, utoipa::ToSchema)]
97pub struct RevokeRequest {
98    token: String,
99    client_id: Option<String>,
100    client_secret: Option<String>,
101}
102
103#[derive(Deserialize, utoipa::ToSchema)]
104pub struct IntrospectRequest {
105    token: String,
106    // Client credentials via POST body (alternative to Basic auth)
107    client_id: Option<String>,
108    client_secret: Option<String>,
109}
110
111/// OAuth protocol routes — NOT CSRF-protected.
112/// These are called by external OAuth clients (not browser requests with cookies).
113pub fn router() -> Router<AppState> {
114    Router::new()
115        .route("/oauth/authorize", get(authorize))
116        .route("/oauth/token", post(token))
117        .route("/oauth/revoke", post(revoke))
118        .route("/oauth/introspect", post(introspect))
119        .route("/oauth/userinfo", get(userinfo).post(userinfo))
120}
121
122/// Consent routes — browser-facing, cookie-authenticated.
123/// Should be wrapped with CSRF middleware.
124pub fn consent_router() -> Router<AppState> {
125    Router::new()
126        .route("/oauth/consent", get(consent).post(consent_decision))
127}
128
129/// Build an OAuth error redirect per RFC 6749 §4.1.2.1.
130///
131/// Once `client_id` and `redirect_uri` are validated, all subsequent errors
132/// must be communicated back to the client via redirect with query parameters.
133fn redirect_error(
134    redirect_uri: &str,
135    error_code: &str,
136    description: &str,
137    state: Option<&str>,
138) -> Response {
139    // If the redirect_uri can't be parsed (shouldn't happen — already validated),
140    // fall back to a plain HTTP error.
141    let Ok(mut url) = url::Url::parse(redirect_uri) else {
142        return Error::InvalidRedirectUri.into_response();
143    };
144    {
145        let mut params = url.query_pairs_mut();
146        params.append_pair("error", error_code);
147        params.append_pair("error_description", description);
148        if let Some(s) = state {
149            params.append_pair("state", s);
150        }
151    }
152    // RFC 6749 §4.1.2 specifies 302 Found for authorization redirects.
153    // Redirect::temporary() produces 307, which preserves HTTP method.
154    // Use explicit 302 for maximum interoperability.
155    (StatusCode::FOUND, [("location", url.to_string())]).into_response()
156}
157
158/// GET /oauth/authorize — authorization endpoint (RFC 6749 §4.1)
159///
160/// Error handling follows RFC 6749 §4.1.2.1:
161/// - Pre-redirect errors (invalid client_id or redirect_uri): HTTP 400
162/// - Post-redirect errors (all others): redirect to redirect_uri with ?error=...
163#[utoipa::path(
164    get,
165    path = "/oauth/authorize",
166    tag = "oauth",
167    params(
168        ("client_id" = String, Query, description = "Client identifier"),
169        ("redirect_uri" = String, Query, description = "Redirect URI"),
170        ("response_type" = String, Query, description = "Must be 'code'"),
171        ("scope" = Option<String>, Query, description = "Space-separated scopes"),
172        ("state" = Option<String>, Query, description = "Client state parameter"),
173        ("code_challenge" = Option<String>, Query, description = "PKCE code challenge"),
174        ("code_challenge_method" = Option<String>, Query, description = "Must be 'S256'"),
175        ("nonce" = Option<String>, Query, description = "OIDC nonce"),
176        ("prompt" = Option<String>, Query, description = "OIDC prompt (none, login, consent)"),
177    ),
178    responses(
179        (status = 302, description = "Redirect with authorization code or error"),
180        (status = 400, description = "Invalid client or redirect URI", body = ErrorBody),
181    )
182)]
183pub(crate) async fn authorize(
184    State(state): State<AppState>,
185    Query(query): Query<AuthorizeQuery>,
186    jar: CookieJar,
187) -> Result<Response, Error> {
188    // --- Phase 1: Pre-redirect validation (HTTP errors) ---
189    // client_id and redirect_uri must be validated FIRST. If either is invalid,
190    // we cannot safely redirect and must return an HTTP error directly.
191
192    let client = db::find_client_by_client_id(&state.db, &query.client_id)
193        .await?
194        .ok_or(Error::InvalidClient)?;
195
196    if !client.redirect_uris.contains(&query.redirect_uri) {
197        return Err(Error::InvalidRedirectUri);
198    }
199
200    // --- Phase 2: Post-redirect validation (error redirects) ---
201    // From here on, all errors redirect back to the client with ?error=...
202    let redirect_uri = &query.redirect_uri;
203    let state_param = query.state.as_deref();
204
205    // response_type
206    if query.response_type != "code" {
207        return Ok(redirect_error(
208            redirect_uri,
209            "unsupported_response_type",
210            "response_type must be 'code'",
211            state_param,
212        ));
213    }
214
215    // PKCE is mandatory
216    let code_challenge = match query.code_challenge.as_deref() {
217        Some(c) => c,
218        None => {
219            return Ok(redirect_error(
220                redirect_uri,
221                "invalid_request",
222                "code_challenge is required",
223                state_param,
224            ));
225        }
226    };
227    let method = query.code_challenge_method.as_deref().unwrap_or("S256");
228    if method != "S256" {
229        return Ok(redirect_error(
230            redirect_uri,
231            "invalid_request",
232            "code_challenge_method must be 'S256'",
233            state_param,
234        ));
235    }
236
237    // Validate and deduplicate requested scopes.
238    // OIDC protocol-level scopes (openid, profile, email) are always accepted.
239    // Custom/resource scopes must be defined in config and allowed for the client.
240    let granted_scopes: Vec<String> = if let Some(ref scope_str) = query.scope {
241        let requested: BTreeSet<&str> = scope_str.split_whitespace().collect();
242        if requested.is_empty() {
243            return Ok(redirect_error(
244                redirect_uri,
245                "invalid_scope",
246                "scope parameter must not be empty",
247                state_param,
248            ));
249        }
250        let defined_names: Vec<&str> = state.config.scopes.definitions.iter()
251            .map(|d| d.name.as_str())
252            .collect();
253        for s in &requested {
254            if PROTOCOL_SCOPES.contains(s) {
255                continue; // OIDC protocol-level, always accepted
256            }
257            if !defined_names.contains(s) {
258                return Ok(redirect_error(
259                    redirect_uri,
260                    "invalid_scope",
261                    &format!("unknown scope: {s}"),
262                    state_param,
263                ));
264            }
265            if !client.allowed_scopes.iter().any(|a| a == s) {
266                return Ok(redirect_error(
267                    redirect_uri,
268                    "invalid_scope",
269                    &format!("scope not allowed for this client: {s}"),
270                    state_param,
271                ));
272            }
273        }
274        requested.into_iter().map(String::from).collect()
275    } else {
276        vec![]
277    };
278
279    // Parse and validate prompt parameter (OIDC Core 1.0 Section 3.1.2.1).
280    let prompt_values: Vec<&str> = query.prompt.as_deref()
281        .map(|p| p.split_whitespace().collect())
282        .unwrap_or_default();
283
284    for v in &prompt_values {
285        if !["none", "login", "consent"].contains(v) {
286            return Ok(redirect_error(
287                redirect_uri,
288                "invalid_request",
289                &format!("unsupported prompt value: {v}"),
290                state_param,
291            ));
292        }
293    }
294    // "none" must not be combined with other values per OIDC Core 1.0 Section 3.1.2.1.
295    if prompt_values.contains(&"none") && prompt_values.len() > 1 {
296        return Ok(redirect_error(
297            redirect_uri,
298            "invalid_request",
299            "prompt=none must not be combined with other values",
300            state_param,
301        ));
302    }
303
304    let prompt_none = prompt_values.contains(&"none");
305    let prompt_login = prompt_values.contains(&"login");
306
307    // prompt=login: force re-authentication by redirecting to login_url.
308    // The authorize request is encoded as a return_to URL so the user
309    // returns here (without prompt=login) after re-authenticating.
310    if prompt_login {
311        match state.config.oauth.login_url.as_deref() {
312            Some(login_url) => {
313                let Ok(mut login_redirect) = url::Url::parse(login_url) else {
314                    tracing::error!(login_url, "configured login_url is not a valid URL");
315                    return Ok(redirect_error(
316                        redirect_uri, "server_error", "internal error", state_param,
317                    ));
318                };
319                // Build the return-to authorize URL without prompt=login to prevent loops
320                let mut return_url = url::Url::parse(&format!(
321                    "{}/oauth/authorize", state.config.server.public_url
322                )).expect("public_url is a valid URL");
323                {
324                    let mut params = return_url.query_pairs_mut();
325                    params.append_pair("client_id", &query.client_id);
326                    params.append_pair("redirect_uri", &query.redirect_uri);
327                    params.append_pair("response_type", &query.response_type);
328                    if let Some(ref s) = query.scope {
329                        params.append_pair("scope", s);
330                    }
331                    if let Some(ref s) = query.state {
332                        params.append_pair("state", s);
333                    }
334                    if let Some(ref c) = query.code_challenge {
335                        params.append_pair("code_challenge", c);
336                    }
337                    if let Some(ref m) = query.code_challenge_method {
338                        params.append_pair("code_challenge_method", m);
339                    }
340                    if let Some(ref n) = query.nonce {
341                        params.append_pair("nonce", n);
342                    }
343                }
344                login_redirect.query_pairs_mut()
345                    .append_pair("return_to", return_url.as_str());
346                return Ok(
347                    (StatusCode::FOUND, [("location", login_redirect.to_string())]).into_response()
348                );
349            }
350            None => {
351                return Ok(redirect_error(
352                    redirect_uri,
353                    "login_required",
354                    "re-authentication required but no login_url is configured",
355                    state_param,
356                ));
357            }
358        }
359    }
360
361    // User must be authenticated (cookie-based) — check BEFORE consent,
362    // because consent evaluation requires knowing who the user is.
363    let access_token = match jar.get(&state.cookie_names.access) {
364        Some(c) => c.value().to_string(),
365        None => {
366            return Ok(redirect_error(
367                redirect_uri,
368                "login_required",
369                "user is not authenticated",
370                state_param,
371            ));
372        }
373    };
374
375    let token_data = match state.keys.verify_session_token(&state.config.jwt, &access_token) {
376        Ok(data) => data,
377        Err(_) => {
378            return Ok(redirect_error(
379                redirect_uri,
380                "login_required",
381                "session is invalid or expired",
382                state_param,
383            ));
384        }
385    };
386
387    let user_id: uuid::Uuid = match token_data.claims.sub.parse() {
388        Ok(id) => id,
389        Err(_) => {
390            return Ok(redirect_error(
391                redirect_uri,
392                "server_error",
393                "internal error",
394                state_param,
395            ));
396        }
397    };
398
399    // Consent check — must come after authentication so the user is known.
400    // For non-auto-approve clients, store the authorization request in the DB
401    // and redirect to the deployer's consent URL.
402    // prompt=none: must never show UI — return consent_required error redirect
403    // instead of redirecting to consent_url.
404    if !client.auto_approve {
405        if prompt_none {
406            return Ok(redirect_error(
407                redirect_uri,
408                "consent_required",
409                "user consent is required",
410                state_param,
411            ));
412        }
413        let consent_url = match state.config.oauth.consent_url.as_deref() {
414            Some(url) => url,
415            None => {
416                // No consent_url configured — cannot redirect to consent UI
417                return Ok(redirect_error(
418                    redirect_uri,
419                    "consent_required",
420                    "user consent is required but no consent_url is configured",
421                    state_param,
422                ));
423            }
424        };
425
426        let expires_at = Utc::now() + Duration::seconds(600); // 10 minutes
427
428        let consent_id = match db::store_consent_request(
429            &state.db,
430            client.id,
431            user_id,
432            &granted_scopes,
433            &query.redirect_uri,
434            query.state.as_deref(),
435            query.code_challenge.as_deref(),
436            query.code_challenge_method.as_deref(),
437            query.nonce.as_deref(),
438            expires_at,
439        )
440        .await
441        {
442            Ok(id) => id,
443            Err(e) => {
444                tracing::error!(error = %e, "failed to store consent request");
445                return Ok(redirect_error(
446                    redirect_uri,
447                    "server_error",
448                    "internal error",
449                    state_param,
450                ));
451            }
452        };
453
454        // Redirect to deployer's consent page with consent_id
455        let Ok(mut consent_redirect) = url::Url::parse(consent_url) else {
456            tracing::error!(consent_url, "configured consent_url is not a valid URL");
457            return Ok(redirect_error(
458                redirect_uri,
459                "server_error",
460                "internal error",
461                state_param,
462            ));
463        };
464        consent_redirect
465            .query_pairs_mut()
466            .append_pair("consent_id", &consent_id.to_string());
467
468        return Ok(
469            (StatusCode::FOUND, [("location", consent_redirect.to_string())]).into_response()
470        );
471    }
472
473    // --- Phase 3: Issue authorization code ---
474    let mut code_bytes = [0u8; 32];
475    rand::RngCore::fill_bytes(&mut rand::rng(), &mut code_bytes);
476    let code = URL_SAFE_NO_PAD.encode(code_bytes);
477    let code_hash = jwt::hash_token(&code);
478
479    let expires_at = Utc::now() + Duration::seconds(
480        state.config.jwt.authorization_code_ttl_secs as i64,
481    );
482
483    if let Err(e) = db::store_authorization_code(
484        &state.db,
485        &code_hash,
486        user_id,
487        client.id,
488        &query.redirect_uri,
489        &granted_scopes,
490        Some(code_challenge),
491        Some(method),
492        query.nonce.as_deref(),
493        expires_at,
494    )
495    .await
496    {
497        tracing::error!(error = %e, "failed to store authorization code");
498        return Ok(redirect_error(
499            redirect_uri,
500            "server_error",
501            "internal error",
502            state_param,
503        ));
504    }
505
506    // Redirect back to client with authorization code
507    let mut redirect_url = match url::Url::parse(&query.redirect_uri) {
508        Ok(url) => url,
509        Err(_) => {
510            return Ok(redirect_error(
511                redirect_uri,
512                "server_error",
513                "internal error",
514                state_param,
515            ));
516        }
517    };
518
519    {
520        let mut params = redirect_url.query_pairs_mut();
521        params.append_pair("code", &code);
522        if let Some(s) = state_param {
523            params.append_pair("state", s);
524        }
525    }
526
527    Ok((StatusCode::FOUND, [("location", redirect_url.to_string())]).into_response())
528}
529
530/// GET /oauth/consent — returns consent context for the deployer's consent UI.
531///
532/// Requires session cookie + consent_id query parameter. The consent request
533/// must exist, not be expired, and belong to the authenticated user.
534#[utoipa::path(
535    get,
536    path = "/oauth/consent",
537    tag = "oauth",
538    params(("consent_id" = uuid::Uuid, Query, description = "Consent request ID")),
539    responses(
540        (status = 200, description = "Consent request details", body = ConsentResponse),
541        (status = 400, description = "Invalid or expired consent request", body = ErrorBody),
542        (status = 401, description = "Not authenticated", body = ErrorBody),
543    )
544)]
545pub(crate) async fn consent(
546    State(state): State<AppState>,
547    Query(query): Query<ConsentQuery>,
548    jar: CookieJar,
549) -> Result<Json<ConsentResponse>, Error> {
550    // User must be authenticated
551    let access_token = jar
552        .get(&state.cookie_names.access)
553        .map(|c| c.value().to_string())
554        .ok_or(Error::Unauthenticated)?;
555
556    let token_data = state.keys.verify_session_token(&state.config.jwt, &access_token)?;
557
558    let user_id: uuid::Uuid = token_data.claims.sub.parse().map_err(|_| Error::InvalidToken)?;
559
560    // Look up the consent request
561    let consent_req = db::find_consent_request(&state.db, query.consent_id)
562        .await?
563        .ok_or(Error::NotFound)?;
564
565    // The consent request must belong to the authenticated user.
566    // Return NotFound (not Forbidden) to avoid revealing that the consent_id
567    // exists for a different user (oracle prevention).
568    if consent_req.user_id != user_id {
569        return Err(Error::NotFound);
570    }
571
572    // Look up the client (by internal id)
573    let client = db::find_client_by_id(&state.db, consent_req.client_id)
574        .await?
575        .ok_or(Error::InvalidClient)?;
576
577    // Resolve scopes to descriptions.
578    // OIDC protocol-level scopes have hardcoded descriptions.
579    let mut scopes = Vec::new();
580    for s in &consent_req.scopes {
581        if s == "openid" {
582            continue; // protocol-level, no consent description needed
583        }
584        if s == "profile" {
585            scopes.push(ConsentScope {
586                name: "profile".to_string(),
587                description: "Read your username, display name, and profile picture".to_string(),
588            });
589            continue;
590        }
591        if s == "email" {
592            scopes.push(ConsentScope {
593                name: "email".to_string(),
594                description: "Read your email address".to_string(),
595            });
596            continue;
597        }
598        let description = state.config.scopes.definitions.iter()
599            .find(|d| d.name == *s)
600            .map(|d| d.description.clone())
601            .unwrap_or_else(|| s.clone()); // Fallback: use scope name as description
602        scopes.push(ConsentScope {
603            name: s.clone(),
604            description,
605        });
606    }
607
608    Ok(Json(ConsentResponse {
609        client: ConsentClient {
610            name: client.name,
611            client_id: client.client_id,
612        },
613        scopes,
614        redirect_uri: consent_req.redirect_uri,
615        state: consent_req.state,
616        expires_at: consent_req.expires_at.to_rfc3339(),
617    }))
618}
619
620/// POST /oauth/consent — user approves or denies the consent request.
621///
622/// If approved: issues authorization code and redirects to the client's redirect_uri.
623/// If denied: redirects with `?error=access_denied`.
624/// Consumes the consent request in either case.
625#[utoipa::path(
626    post,
627    path = "/oauth/consent",
628    tag = "oauth",
629    params(("consent_id" = uuid::Uuid, Query, description = "Consent request ID")),
630    request_body = ConsentDecision,
631    responses(
632        (status = 302, description = "Redirect to client with code or error"),
633        (status = 400, description = "Invalid or expired consent request", body = ErrorBody),
634        (status = 401, description = "Not authenticated", body = ErrorBody),
635    )
636)]
637pub(crate) async fn consent_decision(
638    State(state): State<AppState>,
639    Query(query): Query<ConsentQuery>,
640    jar: CookieJar,
641    Json(body): Json<ConsentDecision>,
642) -> Result<Response, Error> {
643    // User must be authenticated
644    let access_token = jar
645        .get(&state.cookie_names.access)
646        .map(|c| c.value().to_string())
647        .ok_or(Error::Unauthenticated)?;
648
649    let token_data = state.keys.verify_session_token(&state.config.jwt, &access_token)?;
650
651    let user_id: uuid::Uuid = token_data.claims.sub.parse().map_err(|_| Error::InvalidToken)?;
652
653    // Atomically consume the consent request (one-time use, prevents TOCTOU race).
654    // The user_id filter is part of the atomic DELETE so that a wrong user cannot
655    // destroy another user's consent request.
656    let consent_req = db::consume_consent_request(&state.db, query.consent_id, user_id)
657        .await?
658        .ok_or(Error::NotFound)?;
659
660    let redirect_uri = &consent_req.redirect_uri;
661    let state_param = consent_req.state.as_deref();
662
663    if !body.approved {
664        return Ok(redirect_error(
665            redirect_uri,
666            "access_denied",
667            "user denied the authorization request",
668            state_param,
669        ));
670    }
671
672    // Issue authorization code (same logic as auto-approve path in authorize)
673    let mut code_bytes = [0u8; 32];
674    rand::RngCore::fill_bytes(&mut rand::rng(), &mut code_bytes);
675    let code = URL_SAFE_NO_PAD.encode(code_bytes);
676    let code_hash = jwt::hash_token(&code);
677
678    let expires_at = Utc::now() + Duration::seconds(
679        state.config.jwt.authorization_code_ttl_secs as i64,
680    );
681
682    // Look up the client to get the internal id
683    let client = db::find_client_by_id(&state.db, consent_req.client_id)
684        .await?
685        .ok_or(Error::InvalidClient)?;
686
687    // Re-validate redirect_uri against the client's current registered URIs.
688    // The URI was validated at authorize time, but the client config may have
689    // changed during the consent window (up to 10 minutes).
690    if !client.redirect_uris.contains(&consent_req.redirect_uri) {
691        return Ok(redirect_error(
692            redirect_uri,
693            "server_error",
694            "redirect_uri no longer registered for this client",
695            state_param,
696        ));
697    }
698
699    if let Err(e) = db::store_authorization_code(
700        &state.db,
701        &code_hash,
702        user_id,
703        client.id,
704        &consent_req.redirect_uri,
705        &consent_req.scopes,
706        consent_req.code_challenge.as_deref(),
707        consent_req.code_challenge_method.as_deref(),
708        consent_req.nonce.as_deref(),
709        expires_at,
710    )
711    .await
712    {
713        tracing::error!(error = %e, "failed to store authorization code");
714        return Ok(redirect_error(
715            redirect_uri,
716            "server_error",
717            "internal error",
718            state_param,
719        ));
720    }
721
722    // Redirect back to client with authorization code
723    let mut redirect_url = match url::Url::parse(&consent_req.redirect_uri) {
724        Ok(url) => url,
725        Err(_) => {
726            return Ok(redirect_error(
727                redirect_uri,
728                "server_error",
729                "internal error",
730                state_param,
731            ));
732        }
733    };
734
735    {
736        let mut params = redirect_url.query_pairs_mut();
737        params.append_pair("code", &code);
738        if let Some(s) = state_param {
739            params.append_pair("state", s);
740        }
741    }
742
743    Ok((StatusCode::FOUND, [("location", redirect_url.to_string())]).into_response())
744}
745
746/// POST /oauth/token — token endpoint
747#[utoipa::path(
748    post,
749    path = "/oauth/token",
750    tag = "oauth",
751    request_body(content = TokenRequest, content_type = "application/x-www-form-urlencoded"),
752    responses(
753        (status = 200, description = "Token response", body = TokenResponse),
754        (status = 400, description = "Invalid grant or request", body = ErrorBody),
755        (status = 401, description = "Invalid client credentials", body = ErrorBody),
756    )
757)]
758pub(crate) async fn token(
759    State(state): State<AppState>,
760    headers: HeaderMap,
761    Form(body): Form<TokenRequest>,
762) -> Result<Json<TokenResponse>, Error> {
763    // Validate client credentials (Basic auth or POST body)
764    let (client_id_str, client_secret) = extract_client_credentials(
765        &headers,
766        body.client_id.as_deref(),
767        body.client_secret.as_deref(),
768    )?;
769
770    let client = db::find_client_by_client_id(&state.db, &client_id_str)
771        .await?
772        .ok_or(Error::InvalidClient)?;
773
774    let secret_hash = jwt::hash_token(&client_secret);
775    if secret_hash.as_bytes().ct_eq(client.client_secret_hash.as_bytes()).unwrap_u8() == 0 {
776        return Err(Error::InvalidClient);
777    }
778
779    match body.grant_type.as_str() {
780        "authorization_code" => {
781            let code = body.code.as_deref().ok_or(Error::InvalidGrant)?;
782            let redirect_uri = body.redirect_uri.as_deref().ok_or(Error::InvalidGrant)?;
783
784            let code_hash = jwt::hash_token(code);
785
786            // Atomically consume the authorization code (prevents TOCTOU race)
787            let auth_code = db::consume_authorization_code(&state.db, &code_hash)
788                .await?
789                .ok_or(Error::InvalidAuthorizationCode)?;
790
791            // Verify redirect_uri matches
792            if auth_code.redirect_uri != redirect_uri {
793                return Err(Error::InvalidGrant);
794            }
795
796            // Verify client matches
797            if auth_code.client_id != client.id {
798                return Err(Error::InvalidGrant);
799            }
800
801            // Verify PKCE (mandatory — code_challenge should always be present)
802            let challenge = auth_code.code_challenge.as_deref()
803                .ok_or(Error::InvalidGrant)?;
804            let verifier = body.code_verifier.as_deref()
805                .ok_or(Error::InvalidGrant)?;
806
807            let computed = {
808                let mut hasher = Sha256::new();
809                hasher.update(verifier.as_bytes());
810                URL_SAFE_NO_PAD.encode(hasher.finalize())
811            };
812
813            if computed.as_bytes().ct_eq(challenge.as_bytes()).unwrap_u8() == 0 {
814                return Err(Error::InvalidGrant);
815            }
816
817            // Get user
818            let user = db::find_user_by_id(&state.db, auth_code.user_id)
819                .await?
820                .ok_or(Error::UserNotFound)?;
821
822            // Issue tokens with client_id as audience
823            let scope_str = if auth_code.scopes.is_empty() {
824                None
825            } else {
826                Some(auth_code.scopes.join(" "))
827            };
828
829            let access_token = state.keys.sign_access_token_with_scopes(
830                &state.config.jwt,
831                &user.id.to_string(),
832                &user.username,
833                &user.role,
834                &client.client_id,
835                scope_str.as_deref(),
836            )?;
837
838            let (refresh_raw, refresh_hash) = jwt::generate_refresh_token();
839            let family_id = uuid::Uuid::now_v7();
840            let expires_at = Utc::now() + Duration::seconds(
841                state.config.jwt.refresh_token_ttl_secs as i64,
842            );
843            let auth_time = Some(auth_code.created_at.timestamp());
844            db::store_refresh_token(
845                &state.db,
846                user.id,
847                Some(client.id),
848                &refresh_hash,
849                expires_at,
850                &auth_code.scopes,
851                None,
852                None,
853                family_id,
854                auth_code.nonce.as_deref(),
855                auth_time,
856            )
857            .await?;
858
859            // Only issue ID token when openid scope was granted (OIDC Core 1.0)
860            let id_token = if auth_code.scopes.iter().any(|s| s == "openid") {
861                // Include email claims when email scope is granted (OIDC Core 1.0 §5.4)
862                let (email, email_verified) = if auth_code.scopes.iter().any(|s| s == "email") {
863                    let links = db::find_oauth_links_by_user(&state.db, user.id).await?;
864                    match links.iter().find(|l| l.provider_email.is_some()) {
865                        Some(link) => (link.provider_email.clone(), Some(link.email_verified)),
866                        None => (None, None),
867                    }
868                } else {
869                    (None, None)
870                };
871                Some(state.keys.sign_id_token(
872                    &state.config.jwt,
873                    &user.id.to_string(),
874                    &user.username,
875                    user.display_name.as_deref(),
876                    user.avatar_url.as_deref(),
877                    &client.client_id,
878                    auth_code.nonce.as_deref(),
879                    auth_time,
880                    email.as_deref(),
881                    email_verified,
882                    &auth_code.scopes,
883                )?)
884            } else {
885                None
886            };
887
888            Ok(Json(TokenResponse {
889                access_token,
890                token_type: "Bearer",
891                expires_in: state.config.jwt.access_token_ttl_secs,
892                refresh_token: refresh_raw,
893                id_token,
894                scope: scope_str,
895            }))
896        }
897        "refresh_token" => {
898            let refresh_raw = body.refresh_token.as_deref()
899                .ok_or(Error::InvalidGrant)?;
900
901            let refresh_hash = jwt::hash_token(refresh_raw);
902
903            // Check for token reuse — if this hash was already consumed, revoke the family
904            if let Some(family_id) = db::check_token_reuse(&state.db, &refresh_hash).await? {
905                db::revoke_token_family(&state.db, family_id).await?;
906                return Err(Error::InvalidGrant);
907            }
908
909            // Atomically consume a client-bound refresh token (client_id = verified client).
910            // This rejects session tokens or other clients' tokens without consuming them,
911            // preventing cross-endpoint token destruction.
912            let token_row = db::consume_client_refresh_token(&state.db, &refresh_hash, client.id)
913                .await?
914                .ok_or(Error::InvalidGrant)?;
915
916            let user = db::find_user_by_id(&state.db, token_row.user_id)
917                .await?
918                .ok_or(Error::UserNotFound)?;
919
920            // Intersect original scopes with client's current allowed_scopes.
921            // OIDC protocol-level scopes pass through unconditionally.
922            // If an admin revoked a resource scope from the client since the token
923            // was issued, the refreshed token will no longer carry that scope.
924            let mut effective_scopes: Vec<String> = token_row.scopes.iter()
925                .filter(|s| PROTOCOL_SCOPES.contains(&s.as_str()) || client.allowed_scopes.contains(s))
926                .cloned()
927                .collect();
928
929            // Scope downscoping (RFC 6749 §6): if the client requests a narrower
930            // scope set, validate it's a subset of the effective scopes and narrow.
931            if let Some(ref requested_scope) = body.scope {
932                let requested: BTreeSet<&str> = requested_scope.split_whitespace().collect();
933                let effective_set: BTreeSet<&str> = effective_scopes.iter().map(|s| s.as_str()).collect();
934                for s in &requested {
935                    if !effective_set.contains(s) {
936                        return Err(Error::InvalidScope);
937                    }
938                }
939                effective_scopes = requested.into_iter().map(String::from).collect();
940            }
941
942            let scope_str = if effective_scopes.is_empty() {
943                None
944            } else {
945                Some(effective_scopes.join(" "))
946            };
947
948            let access_token = state.keys.sign_access_token_with_scopes(
949                &state.config.jwt,
950                &user.id.to_string(),
951                &user.username,
952                &user.role,
953                &client.client_id,
954                scope_str.as_deref(),
955            )?;
956
957            let (new_refresh_raw, new_refresh_hash) = jwt::generate_refresh_token();
958            let expires_at = Utc::now() + Duration::seconds(
959                state.config.jwt.refresh_token_ttl_secs as i64,
960            );
961            db::store_refresh_token(
962                &state.db,
963                user.id,
964                Some(client.id),
965                &new_refresh_hash,
966                expires_at,
967                &effective_scopes,
968                None,
969                None,
970                token_row.family_id,
971                token_row.nonce.as_deref(),
972                token_row.auth_time,
973            )
974            .await?;
975
976            // Only issue ID token when openid scope is in the effective scopes.
977            // Nonce and auth_time are preserved from the original authorization
978            // request across refresh rotations (OIDC Core 1.0 §12.2).
979            let id_token = if effective_scopes.iter().any(|s| s == "openid") {
980                // Include email claims when email scope is granted (OIDC Core 1.0 §5.4)
981                let (email, email_verified) = if effective_scopes.iter().any(|s| s == "email") {
982                    let links = db::find_oauth_links_by_user(&state.db, user.id).await?;
983                    match links.iter().find(|l| l.provider_email.is_some()) {
984                        Some(link) => (link.provider_email.clone(), Some(link.email_verified)),
985                        None => (None, None),
986                    }
987                } else {
988                    (None, None)
989                };
990                Some(state.keys.sign_id_token(
991                    &state.config.jwt,
992                    &user.id.to_string(),
993                    &user.username,
994                    user.display_name.as_deref(),
995                    user.avatar_url.as_deref(),
996                    &client.client_id,
997                    token_row.nonce.as_deref(),
998                    token_row.auth_time,
999                    email.as_deref(),
1000                    email_verified,
1001                    &effective_scopes,
1002                )?)
1003            } else {
1004                None
1005            };
1006
1007            Ok(Json(TokenResponse {
1008                access_token,
1009                token_type: "Bearer",
1010                expires_in: state.config.jwt.access_token_ttl_secs,
1011                refresh_token: new_refresh_raw,
1012                id_token,
1013                scope: scope_str,
1014            }))
1015        }
1016        _ => Err(Error::UnsupportedGrantType),
1017    }
1018}
1019
1020/// POST /oauth/revoke — revoke a refresh token (RFC 7009)
1021#[utoipa::path(
1022    post,
1023    path = "/oauth/revoke",
1024    tag = "oauth",
1025    request_body(content = RevokeRequest, content_type = "application/x-www-form-urlencoded"),
1026    responses(
1027        (status = 200, description = "Token revoked (or already invalid)"),
1028        (status = 401, description = "Invalid client credentials", body = ErrorBody),
1029    )
1030)]
1031pub(crate) async fn revoke(
1032    State(state): State<AppState>,
1033    headers: HeaderMap,
1034    Form(body): Form<RevokeRequest>,
1035) -> Result<StatusCode, Error> {
1036    // Validate client (Basic auth or POST body)
1037    let (client_id_str, client_secret) = extract_client_credentials(
1038        &headers,
1039        body.client_id.as_deref(),
1040        body.client_secret.as_deref(),
1041    )?;
1042
1043    let client = db::find_client_by_client_id(&state.db, &client_id_str)
1044        .await?
1045        .ok_or(Error::InvalidClient)?;
1046
1047    let secret_hash = jwt::hash_token(&client_secret);
1048    if secret_hash.as_bytes().ct_eq(client.client_secret_hash.as_bytes()).unwrap_u8() == 0 {
1049        return Err(Error::InvalidClient);
1050    }
1051
1052    // Revoke the token, scoped to this client (RFC 7009 says always return 200)
1053    let token_hash = jwt::hash_token(&body.token);
1054    if let Err(e) = db::delete_refresh_token_for_client(&state.db, &token_hash, client.id).await {
1055        tracing::warn!(error = %e, client_id = %client_id_str, "token revocation failed");
1056    }
1057
1058    Ok(StatusCode::OK)
1059}
1060
1061/// POST /oauth/introspect — Token Introspection (RFC 7662)
1062///
1063/// Allows registered OAuth clients to validate tokens and retrieve their claims.
1064/// Accepts client authentication via HTTP Basic auth or POST body (client_id + client_secret).
1065/// Returns `{"active": false}` for any invalid, expired, or revoked token.
1066#[utoipa::path(
1067    post,
1068    path = "/oauth/introspect",
1069    tag = "oauth",
1070    request_body(content = IntrospectRequest, content_type = "application/x-www-form-urlencoded"),
1071    responses(
1072        (status = 200, description = "Introspection response (active: true/false)"),
1073        (status = 401, description = "Invalid client credentials", body = ErrorBody),
1074    )
1075)]
1076pub(crate) async fn introspect(
1077    State(state): State<AppState>,
1078    headers: HeaderMap,
1079    Form(body): Form<IntrospectRequest>,
1080) -> Result<impl IntoResponse, Error> {
1081    let no_cache_headers = || [
1082        (axum::http::header::CACHE_CONTROL, "no-store"),
1083        (axum::http::header::PRAGMA, "no-cache"),
1084    ];
1085    let inactive = || Ok((no_cache_headers(), Json(serde_json::json!({"active": false}))));
1086
1087    // Authenticate the client via Basic auth or POST body credentials
1088    let (client_id_str, client_secret) = extract_client_credentials(
1089        &headers,
1090        body.client_id.as_deref(),
1091        body.client_secret.as_deref(),
1092    )?;
1093
1094    let client = db::find_client_by_client_id(&state.db, &client_id_str)
1095        .await?
1096        .ok_or(Error::InvalidClient)?;
1097
1098    let secret_hash = jwt::hash_token(&client_secret);
1099    if secret_hash.as_bytes().ct_eq(client.client_secret_hash.as_bytes()).unwrap_u8() == 0 {
1100        return Err(Error::InvalidClient);
1101    }
1102
1103    // Decode and verify the token — any failure means inactive.
1104    // verify_client_token rejects session tokens (aud == issuer).
1105    let token_data = match state.keys.verify_client_token(&state.config.jwt, &body.token) {
1106        Ok(data) => data,
1107        Err(_) => return inactive(),
1108    };
1109
1110    let claims = &token_data.claims;
1111
1112    // Check the user still exists and is not soft-deleted
1113    let user_id: uuid::Uuid = match claims.sub.parse() {
1114        Ok(id) => id,
1115        Err(_) => return inactive(),
1116    };
1117
1118    if db::find_user_by_id(&state.db, user_id).await?.is_none() {
1119        return inactive();
1120    }
1121
1122    // Build RFC 7662 response
1123    let mut response = serde_json::json!({
1124        "active": true,
1125        "sub": claims.sub,
1126        "aud": claims.aud,
1127        "iss": claims.iss,
1128        "exp": claims.exp,
1129        "iat": claims.iat,
1130        "username": claims.username,
1131        "token_type": "Bearer",
1132        "client_id": claims.aud,
1133    });
1134
1135    if let Some(ref scope) = claims.scope {
1136        response["scope"] = serde_json::Value::String(scope.clone());
1137    }
1138
1139    Ok((no_cache_headers(), Json(response)))
1140}
1141
1142/// Extract client credentials from Basic auth header or POST body.
1143///
1144/// Tries HTTP Basic auth first (RFC 6749 §2.3.1), then falls back to
1145/// POST body client_id/client_secret (RFC 6749 §2.3.1 alternative).
1146fn extract_client_credentials(
1147    headers: &HeaderMap,
1148    body_client_id: Option<&str>,
1149    body_client_secret: Option<&str>,
1150) -> Result<(String, String), Error> {
1151    // Try Basic auth first (RFC 7617)
1152    if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
1153        if auth_header.len() > 6 && auth_header[..6].eq_ignore_ascii_case("basic ") {
1154            let b64 = auth_header[6..].trim();
1155            let decoded = base64::engine::general_purpose::STANDARD
1156                .decode(b64)
1157                .or_else(|_| URL_SAFE_NO_PAD.decode(b64))
1158                .map_err(|_| Error::InvalidClient)?;
1159            let decoded_str = String::from_utf8(decoded).map_err(|_| Error::InvalidClient)?;
1160            let (id, secret) = decoded_str.split_once(':').ok_or(Error::InvalidClient)?;
1161            // RFC 6749 §2.3.1: credentials are application/x-www-form-urlencoded
1162            let id = percent_encoding::percent_decode_str(id)
1163                .decode_utf8()
1164                .map_err(|_| Error::InvalidClient)?
1165                .into_owned();
1166            let secret = percent_encoding::percent_decode_str(secret)
1167                .decode_utf8()
1168                .map_err(|_| Error::InvalidClient)?
1169                .into_owned();
1170            return Ok((id, secret));
1171        }
1172    }
1173
1174    // Fall back to POST body credentials
1175    match (body_client_id, body_client_secret) {
1176        (Some(id), Some(secret)) => Ok((id.to_string(), secret.to_string())),
1177        _ => Err(Error::InvalidClient),
1178    }
1179}
1180
1181/// GET /oauth/userinfo — OIDC UserInfo endpoint (OpenID Connect Core 1.0 §5.3)
1182///
1183/// Accepts a Bearer access token via the Authorization header. The token must
1184/// have been issued to an OAuth client (aud != issuer). Returns profile claims
1185/// filtered by the scopes granted in the access token.
1186///
1187/// **Note:** POST is also supported on this endpoint per OIDC Core 1.0 §5.3.
1188#[utoipa::path(
1189    get,
1190    path = "/oauth/userinfo",
1191    tag = "oauth",
1192    description = "OIDC UserInfo endpoint. Both GET and POST are supported per OIDC Core 1.0 §5.3.",
1193    responses(
1194        (status = 200, description = "User identity claims (filtered by token scopes)"),
1195        (status = 401, description = "Invalid or missing bearer token", body = ErrorBody),
1196    ),
1197    security(("bearer" = []))
1198)]
1199pub(crate) async fn userinfo(
1200    State(state): State<AppState>,
1201    headers: HeaderMap,
1202) -> Response {
1203    match userinfo_inner(&state, &headers).await {
1204        Ok(json) => Json(json).into_response(),
1205        Err(err) => bearer_error_response(&state.config.jwt.issuer, err),
1206    }
1207}
1208
1209/// Attach `WWW-Authenticate: Bearer` to an error response per RFC 6750 §3.1.
1210fn bearer_error_response(issuer: &str, err: Error) -> Response {
1211    let header_value = www_authenticate_value(issuer, &err);
1212    let mut resp = err.into_response();
1213    if let Some(value) = header_value {
1214        resp.headers_mut().insert(
1215            axum::http::header::WWW_AUTHENTICATE,
1216            value.parse().unwrap(),
1217        );
1218    }
1219    resp
1220}
1221
1222async fn userinfo_inner(
1223    state: &AppState,
1224    headers: &HeaderMap,
1225) -> Result<serde_json::Value, Error> {
1226    // Extract Bearer token from Authorization header (case-insensitive prefix per RFC 6750 §2.1)
1227    let auth_header = headers
1228        .get("authorization")
1229        .and_then(|v| v.to_str().ok())
1230        .ok_or(Error::Unauthenticated)?;
1231
1232    let bearer_token = if auth_header.len() > 7 && auth_header[..7].eq_ignore_ascii_case("bearer ") {
1233        &auth_header[7..]
1234    } else {
1235        return Err(Error::Unauthenticated);
1236    };
1237
1238    // Validate the JWT — verify_client_token rejects session tokens (aud == issuer).
1239    // Session tokens are not valid for UserInfo — use /auth/me instead.
1240    let token_data = state
1241        .keys
1242        .verify_client_token(&state.config.jwt, bearer_token)?;
1243
1244    let claims = &token_data.claims;
1245
1246    // Verify the audience matches a registered client
1247    db::find_client_by_client_id(&state.db, &claims.aud)
1248        .await?
1249        .ok_or(Error::InvalidToken)?;
1250
1251    // Parse user ID
1252    let user_id: uuid::Uuid = claims.sub.parse().map_err(|_| Error::InvalidToken)?;
1253
1254    // Fetch user profile — return 401 (not 404) if user was deleted,
1255    // since the token is no longer valid for any resource endpoint.
1256    let user = db::find_user_by_id(&state.db, user_id)
1257        .await?
1258        .ok_or(Error::InvalidToken)?;
1259
1260    // Parse granted scopes from the token
1261    let scopes: BTreeSet<&str> = claims
1262        .scope
1263        .as_deref()
1264        .map(|s| s.split_whitespace().collect())
1265        .unwrap_or_default();
1266
1267    // UserInfo requires the "openid" scope (OIDC Core 1.0 §5.3)
1268    if !scopes.contains("openid") {
1269        return Err(Error::Forbidden);
1270    }
1271
1272    // Build response based on granted scopes (OIDC Core 1.0 §5.4)
1273    let mut response = serde_json::Map::new();
1274
1275    // "sub" is always returned per OIDC Core 1.0 §5.3.2
1276    response.insert("sub".to_string(), serde_json::json!(user.id.to_string()));
1277
1278    if scopes.contains("profile") {
1279        response.insert(
1280            "preferred_username".to_string(),
1281            serde_json::json!(user.username),
1282        );
1283        if let Some(ref name) = user.display_name {
1284            response.insert("name".to_string(), serde_json::json!(name));
1285        }
1286        if let Some(ref picture) = user.avatar_url {
1287            response.insert("picture".to_string(), serde_json::json!(picture));
1288        }
1289        response.insert(
1290            "updated_at".to_string(),
1291            serde_json::json!(user.updated_at.timestamp()),
1292        );
1293    }
1294
1295    if scopes.contains("email") {
1296        // Fetch email from the user's oldest oauth_link that has an email (deterministic ordering)
1297        let links = db::find_oauth_links_by_user(&state.db, user_id).await?;
1298        if let Some(link) = links.iter().find(|l| l.provider_email.is_some()) {
1299            response.insert("email".to_string(), serde_json::json!(link.provider_email.as_deref().unwrap()));
1300            response.insert("email_verified".to_string(), serde_json::json!(link.email_verified));
1301        }
1302    }
1303
1304    Ok(serde_json::Value::Object(response))
1305}