Skip to main content

riley_auth_api/routes/
auth.rs

1use std::net::SocketAddr;
2
3use axum::extract::{ConnectInfo, Path, Query, State};
4use axum::http::{HeaderMap, StatusCode};
5use axum::response::Redirect;
6use axum::routing::{get, patch, post};
7use axum::{Json, Router};
8use axum_extra::extract::cookie::{Cookie, SameSite};
9use axum_extra::extract::CookieJar;
10use chrono::{Duration, Utc};
11use serde::{Deserialize, Serialize};
12
13use subtle::ConstantTimeEq;
14
15use riley_auth_core::config::{AccountMergePolicy, Config};
16use riley_auth_core::db;
17use riley_auth_core::error::{Error, ErrorBody};
18use riley_auth_core::jwt::{self, Keys};
19use riley_auth_core::oauth::{self, ResolvedProvider};
20use riley_auth_core::webhooks;
21
22use crate::server::AppState;
23
24// --- Query params ---
25
26#[derive(Deserialize)]
27pub struct CallbackQuery {
28    code: String,
29    state: String,
30}
31
32#[derive(Deserialize, utoipa::ToSchema)]
33pub struct SetupRequest {
34    username: String,
35}
36
37#[derive(Serialize, utoipa::ToSchema)]
38pub struct MeResponse {
39    id: String,
40    username: String,
41    display_name: Option<String>,
42    avatar_url: Option<String>,
43    role: String,
44}
45
46#[derive(Serialize, utoipa::ToSchema)]
47pub struct LinkResponse {
48    provider: String,
49    provider_email: Option<String>,
50    created_at: String,
51}
52
53#[derive(Deserialize, utoipa::ToSchema)]
54pub struct UpdateDisplayNameRequest {
55    display_name: String,
56}
57
58#[derive(Deserialize, utoipa::ToSchema)]
59pub struct UpdateUsernameRequest {
60    username: String,
61}
62
63pub fn router() -> Router<AppState> {
64    Router::new()
65        // OAuth consumer
66        .route("/auth/{provider}", get(auth_redirect))
67        .route("/auth/{provider}/callback", get(auth_callback))
68        .route("/auth/setup", post(auth_setup))
69        .route("/auth/link/confirm", post(link_confirm))
70        // Session
71        .route("/auth/refresh", post(auth_refresh))
72        .route("/auth/logout", post(auth_logout))
73        .route("/auth/logout-all", post(auth_logout_all))
74        .route("/auth/sessions", get(list_sessions))
75        .route("/auth/sessions/{id}", axum::routing::delete(revoke_session))
76        // Profile
77        .route("/auth/me", get(auth_me).patch(update_display_name).delete(delete_account))
78        .route("/auth/me/username", patch(update_username))
79        .route("/auth/me/links", get(list_links))
80        // Provider linking
81        .route("/auth/link/{provider}", get(link_redirect).delete(unlink_provider))
82        .route("/auth/link/{provider}/callback", get(link_callback))
83}
84
85// --- OAuth Consumer Endpoints ---
86
87/// GET /auth/{provider} — redirect to OAuth provider
88#[utoipa::path(
89    get,
90    path = "/auth/{provider}",
91    tag = "auth",
92    params(("provider" = String, Path, description = "OAuth provider name")),
93    responses(
94        (status = 302, description = "Redirect to OAuth provider"),
95        (status = 404, description = "Unknown provider", body = ErrorBody),
96    )
97)]
98pub(crate) async fn auth_redirect(
99    State(state): State<AppState>,
100    Path(provider_name): Path<String>,
101    jar: CookieJar,
102) -> Result<(CookieJar, Redirect), Error> {
103    let provider = find_provider(&state.providers, &provider_name)?;
104    let callback_url = format!(
105        "{}/auth/{}/callback",
106        state.config.server.public_url, provider_name
107    );
108
109    let oauth_state = oauth::generate_state();
110    let (pkce_verifier, pkce_challenge) = oauth::generate_pkce();
111
112    let auth_url = oauth::build_auth_url(
113        provider,
114        &callback_url,
115        &oauth_state,
116        &pkce_challenge,
117    )?;
118
119    let jar = jar
120        .add(build_temp_cookie(&state.cookie_names.oauth_state, &oauth_state, &state.config))
121        .add(build_temp_cookie(&state.cookie_names.pkce, &pkce_verifier, &state.config));
122
123    Ok((jar, Redirect::temporary(&auth_url)))
124}
125
126/// GET /auth/{provider}/callback — OAuth callback
127#[utoipa::path(
128    get,
129    path = "/auth/{provider}/callback",
130    tag = "auth",
131    params(("provider" = String, Path, description = "OAuth provider name")),
132    responses(
133        (status = 302, description = "Redirect after OAuth callback"),
134        (status = 400, description = "Invalid OAuth state or code", body = ErrorBody),
135    )
136)]
137pub(crate) async fn auth_callback(
138    State(state): State<AppState>,
139    ConnectInfo(addr): ConnectInfo<SocketAddr>,
140    Path(provider_name): Path<String>,
141    Query(query): Query<CallbackQuery>,
142    headers: HeaderMap,
143    jar: CookieJar,
144) -> Result<(CookieJar, Redirect), Error> {
145    let provider = find_provider(&state.providers, &provider_name)?;
146
147    // Verify state
148    let saved_state = jar
149        .get(&state.cookie_names.oauth_state)
150        .map(|c| c.value().to_string())
151        .ok_or(Error::InvalidOAuthState)?;
152
153    if query.state.as_bytes().ct_eq(saved_state.as_bytes()).unwrap_u8() == 0 {
154        return Err(Error::InvalidOAuthState);
155    }
156
157    // Get PKCE verifier
158    let pkce_verifier = jar
159        .get(&state.cookie_names.pkce)
160        .map(|c| c.value().to_string())
161        .ok_or(Error::InvalidOAuthState)?;
162
163    let callback_url = format!(
164        "{}/auth/{}/callback",
165        state.config.server.public_url, provider_name
166    );
167
168    // Exchange code for access token
169    let provider_token = oauth::exchange_code(
170        provider,
171        &query.code,
172        &callback_url,
173        &pkce_verifier,
174        &state.oauth_client,
175    )
176    .await?;
177
178    // Fetch profile from provider
179    let profile = oauth::fetch_profile(provider, &provider_token, &state.oauth_client).await?;
180
181    // Clear temp cookies
182    let jar = jar
183        .remove(removal_cookie(&state.cookie_names.oauth_state, "/", &state.config))
184        .remove(removal_cookie(&state.cookie_names.pkce, "/", &state.config));
185
186    // Look up existing oauth link
187    if let Some(link) = db::find_oauth_link(&state.db, &profile.provider, &profile.provider_id).await? {
188        // Returning user — issue tokens
189        let user = db::find_user_by_id(&state.db, link.user_id)
190            .await?
191            .ok_or(Error::UserNotFound)?;
192
193        let ua = headers.get(axum::http::header::USER_AGENT).and_then(|v| v.to_str().ok());
194        let ip = client_ip_string(&headers, addr, state.config.server.behind_proxy);
195        let (jar, _) = issue_tokens(&state, jar, &user, ua, Some(&ip)).await?;
196        webhooks::dispatch_event(
197            &state.db,
198            webhooks::SESSION_CREATED,
199            serde_json::json!({ "user_id": user.id.to_string() }),
200            state.config.webhooks.max_retry_attempts,
201        ).await;
202        return Ok((jar, Redirect::temporary(&state.config.server.public_url)));
203    }
204
205    // Check for email match (auto-merge or suggest linking)
206    if let Some(ref email) = profile.email {
207        let matching_links = db::find_oauth_links_by_email(&state.db, email).await?;
208        if !matching_links.is_empty() {
209            // Auto-merge: when policy is verified_email, BOTH the new provider and the
210            // existing link(s) must have verified the email, and exactly one user matches.
211            if state.config.oauth.account_merge_policy == AccountMergePolicy::VerifiedEmail
212                && profile.email_verified
213            {
214                // Only consider links where the existing provider also verified the email
215                let verified_links: Vec<&db::OAuthLink> = matching_links.iter()
216                    .filter(|l| l.email_verified)
217                    .collect();
218
219                // Collect distinct user IDs from verified links only
220                let mut user_ids: Vec<uuid::Uuid> = verified_links.iter().map(|l| l.user_id).collect();
221                user_ids.sort();
222                user_ids.dedup();
223
224                if user_ids.len() == 1 {
225                    let existing_user_id = user_ids[0];
226                    let user = db::find_user_by_id(&state.db, existing_user_id)
227                        .await?
228                        .ok_or(Error::UserNotFound)?;
229
230                    // Create the link — if a concurrent request already created it
231                    // (double-click, redirect replay), treat as idempotent success.
232                    let link_created = match db::create_oauth_link(
233                        &state.db,
234                        existing_user_id,
235                        &profile.provider,
236                        &profile.provider_id,
237                        profile.email.as_deref(),
238                        profile.email_verified,
239                    ).await {
240                        Ok(_) => true,
241                        Err(e) if riley_auth_core::error::is_unique_violation(&e) => false,
242                        Err(e) => return Err(e),
243                    };
244
245                    let ua = headers.get(axum::http::header::USER_AGENT).and_then(|v| v.to_str().ok());
246                    let ip = client_ip_string(&headers, addr, state.config.server.behind_proxy);
247                    let (jar, _) = issue_tokens(&state, jar, &user, ua, Some(&ip)).await?;
248
249                    if link_created {
250                        webhooks::dispatch_event(
251                            &state.db,
252                            webhooks::LINK_CREATED,
253                            serde_json::json!({
254                                "user_id": user.id.to_string(),
255                                "provider": profile.provider,
256                            }),
257                            state.config.webhooks.max_retry_attempts,
258                        ).await;
259                    }
260                    webhooks::dispatch_event(
261                        &state.db,
262                        webhooks::SESSION_CREATED,
263                        serde_json::json!({ "user_id": user.id.to_string() }),
264                        state.config.webhooks.max_retry_attempts,
265                    ).await;
266
267                    return Ok((jar, Redirect::temporary(&state.config.server.public_url)));
268                }
269            }
270
271            // Fall back to link-accounts redirect (no merge policy, email not verified,
272            // or multiple matching users)
273            let setup_token = create_setup_token(&state.keys, &state.config, &profile)?;
274            let jar = jar.add(build_temp_cookie(&state.cookie_names.setup, &setup_token, &state.config));
275            let mut redirect_url = url::Url::parse(&format!(
276                "{}/link-accounts", state.config.server.public_url
277            )).map_err(|_| Error::Config("invalid public_url".to_string()))?;
278            redirect_url.query_pairs_mut()
279                .append_pair("provider", &profile.provider)
280                .append_pair("email", email);
281            return Ok((jar, Redirect::temporary(redirect_url.as_str())));
282        }
283    }
284
285    // New user — redirect to onboarding with setup token
286    let setup_token = create_setup_token(&state.keys, &state.config, &profile)?;
287    let jar = jar.add(build_temp_cookie(&state.cookie_names.setup, &setup_token, &state.config));
288    let redirect_url = format!("{}/onboarding", state.config.server.public_url);
289    Ok((jar, Redirect::temporary(&redirect_url)))
290}
291
292/// POST /auth/setup — create account with username after OAuth
293#[utoipa::path(
294    post,
295    path = "/auth/setup",
296    tag = "auth",
297    request_body = SetupRequest,
298    responses(
299        (status = 200, description = "Account created", body = MeResponse),
300        (status = 400, description = "Invalid username", body = ErrorBody),
301        (status = 401, description = "Missing or invalid setup token", body = ErrorBody),
302        (status = 409, description = "Username taken", body = ErrorBody),
303    )
304)]
305pub(crate) async fn auth_setup(
306    State(state): State<AppState>,
307    ConnectInfo(addr): ConnectInfo<SocketAddr>,
308    headers: HeaderMap,
309    jar: CookieJar,
310    Json(body): Json<SetupRequest>,
311) -> Result<(CookieJar, Json<MeResponse>), Error> {
312    // Get setup token
313    let setup_token = jar
314        .get(&state.cookie_names.setup)
315        .map(|c| c.value().to_string())
316        .ok_or(Error::Unauthenticated)?;
317
318    let profile = decode_setup_token(&state.keys, &state.config, &setup_token)?;
319
320    // Validate username
321    validate_username(&body.username, &state.config, &state.username_regex)?;
322
323    // Check availability
324    if db::find_user_by_username(&state.db, &body.username).await?.is_some() {
325        return Err(Error::UsernameTaken);
326    }
327    if db::is_username_held(&state.db, &body.username, uuid::Uuid::nil()).await? {
328        return Err(Error::UsernameTaken);
329    }
330
331    // Check if this provider identity is already linked to another user
332    if db::find_oauth_link(&state.db, &profile.provider, &profile.provider_id)
333        .await?
334        .is_some()
335    {
336        return Err(Error::ProviderAlreadyLinked);
337    }
338
339    // Create user + OAuth link atomically
340    let user = db::create_user_with_link(
341        &state.db,
342        &body.username,
343        profile.name.as_deref(),
344        profile.avatar_url.as_deref(),
345        &profile.provider,
346        &profile.provider_id,
347        profile.email.as_deref(),
348        profile.email_verified,
349    )
350    .await
351    .map_err(|e| {
352        // Race between pre-check and insert: distinguish which constraint was violated
353        if let Some(constraint) = riley_auth_core::error::unique_violation_constraint(&e) {
354            if constraint.contains("oauth_links") {
355                Error::ProviderAlreadyLinked
356            } else {
357                Error::UsernameTaken
358            }
359        } else {
360            e
361        }
362    })?;
363
364    // Issue tokens, clear setup cookie
365    let jar = jar.remove(removal_cookie(&state.cookie_names.setup, "/", &state.config));
366    let ua = headers.get(axum::http::header::USER_AGENT).and_then(|v| v.to_str().ok());
367    let ip = client_ip_string(&headers, addr, state.config.server.behind_proxy);
368    let (jar, _) = issue_tokens(&state, jar, &user, ua, Some(&ip)).await?;
369
370    webhooks::dispatch_event(
371        &state.db,
372        webhooks::USER_CREATED,
373        serde_json::json!({ "user_id": user.id.to_string(), "username": user.username }),
374        state.config.webhooks.max_retry_attempts,
375    ).await;
376    webhooks::dispatch_event(
377        &state.db,
378        webhooks::SESSION_CREATED,
379        serde_json::json!({ "user_id": user.id.to_string() }),
380        state.config.webhooks.max_retry_attempts,
381    ).await;
382
383    Ok((jar, Json(user_to_me(&user))))
384}
385
386/// POST /auth/link/confirm — confirm linking a new provider to an existing account
387///
388/// Used when auth_callback detects an email collision and redirects to /link-accounts
389/// with a setup token. The frontend shows a "link this account?" prompt, and the user
390/// confirms by calling this endpoint with both their session cookie and the setup token.
391#[utoipa::path(
392    post,
393    path = "/auth/link/confirm",
394    tag = "auth",
395    responses(
396        (status = 200, description = "Provider linked, account merged if applicable", body = MeResponse),
397        (status = 401, description = "Not authenticated", body = ErrorBody),
398    )
399)]
400pub(crate) async fn link_confirm(
401    State(state): State<AppState>,
402    jar: CookieJar,
403) -> Result<(CookieJar, Json<MeResponse>), Error> {
404    // User must be authenticated
405    let claims = extract_user(&state, &jar)?;
406    let user_id = claims.sub_uuid()?;
407
408    // Get setup token
409    let setup_token = jar
410        .get(&state.cookie_names.setup)
411        .map(|c| c.value().to_string())
412        .ok_or(Error::Unauthenticated)?;
413
414    let profile = decode_setup_token(&state.keys, &state.config, &setup_token)?;
415
416    // Check if this provider account is already linked
417    if let Some(existing) = db::find_oauth_link(&state.db, &profile.provider, &profile.provider_id).await? {
418        if existing.user_id == user_id {
419            // Idempotent: already linked to this user (double-click / replay)
420            let jar = jar.remove(removal_cookie(&state.cookie_names.setup, "/", &state.config));
421            let user = db::find_user_by_id(&state.db, user_id).await?.ok_or(Error::UserNotFound)?;
422            return Ok((jar, Json(user_to_me(&user))));
423        }
424        return Err(Error::ProviderAlreadyLinked);
425    }
426
427    // Create the link — if a concurrent request already created it,
428    // treat as idempotent success for the same user.
429    let link_created = match db::create_oauth_link(
430        &state.db,
431        user_id,
432        &profile.provider,
433        &profile.provider_id,
434        profile.email.as_deref(),
435        profile.email_verified,
436    ).await {
437        Ok(_) => true,
438        Err(e) if riley_auth_core::error::is_unique_violation(&e) => {
439            // Concurrent insert won — verify it was by the same user
440            match db::find_oauth_link(&state.db, &profile.provider, &profile.provider_id).await? {
441                Some(link) if link.user_id == user_id => false,
442                _ => return Err(Error::ProviderAlreadyLinked),
443            }
444        }
445        Err(e) => return Err(e),
446    };
447
448    // Clear setup cookie
449    let jar = jar.remove(removal_cookie(&state.cookie_names.setup, "/", &state.config));
450
451    // Dispatch webhook only if we actually created the link
452    if link_created {
453        webhooks::dispatch_event(
454            &state.db,
455            webhooks::LINK_CREATED,
456            serde_json::json!({
457                "user_id": user_id.to_string(),
458                "provider": profile.provider,
459            }),
460            state.config.webhooks.max_retry_attempts,
461        ).await;
462    }
463
464    // Return updated user profile
465    let user = db::find_user_by_id(&state.db, user_id)
466        .await?
467        .ok_or(Error::UserNotFound)?;
468
469    Ok((jar, Json(user_to_me(&user))))
470}
471
472// --- Session Endpoints ---
473
474/// POST /auth/refresh — exchange refresh cookie for new access token
475#[utoipa::path(
476    post,
477    path = "/auth/refresh",
478    tag = "auth",
479    responses(
480        (status = 200, description = "Tokens refreshed (new cookies set)"),
481        (status = 401, description = "Invalid or expired refresh token", body = ErrorBody),
482    )
483)]
484pub(crate) async fn auth_refresh(
485    State(state): State<AppState>,
486    ConnectInfo(addr): ConnectInfo<SocketAddr>,
487    headers: HeaderMap,
488    jar: CookieJar,
489) -> Result<(CookieJar, StatusCode), Error> {
490    let refresh_raw = jar
491        .get(&state.cookie_names.refresh)
492        .map(|c| c.value().to_string())
493        .ok_or(Error::Unauthenticated)?;
494
495    let refresh_hash = jwt::hash_token(&refresh_raw);
496
497    // Check for token reuse — if this hash was already consumed, an attacker
498    // is replaying a stolen token. Revoke the entire family.
499    if let Some(family_id) = db::check_token_reuse(&state.db, &refresh_hash).await? {
500        db::revoke_token_family(&state.db, family_id).await?;
501        return Err(Error::InvalidToken);
502    }
503
504    // Atomically consume a session-only refresh token (client_id IS NULL).
505    // This rejects client-bound tokens without consuming them, preventing
506    // accidental destruction of OAuth client tokens at the session endpoint.
507    let token_row = db::consume_session_refresh_token(&state.db, &refresh_hash)
508        .await?
509        .ok_or(Error::InvalidToken)?;
510
511    let user = db::find_user_by_id(&state.db, token_row.user_id)
512        .await?
513        .ok_or(Error::UserNotFound)?;
514
515    let ua = headers.get(axum::http::header::USER_AGENT).and_then(|v| v.to_str().ok());
516    let ip = client_ip_string(&headers, addr, state.config.server.behind_proxy);
517
518    // Issue new tokens, inheriting the family_id from the consumed token
519    let access_token = state.keys.sign_access_token(
520        &state.config.jwt,
521        &user.id.to_string(),
522        &user.username,
523        &user.role,
524        &state.config.jwt.issuer,
525    )?;
526
527    const MAX_USER_AGENT_BYTES: usize = 512;
528    let ua_truncated = ua.map(|ua| &ua[..ua.floor_char_boundary(MAX_USER_AGENT_BYTES)]);
529
530    let (new_refresh_raw, new_refresh_hash) = jwt::generate_refresh_token();
531    let expires_at = Utc::now() + Duration::seconds(state.config.jwt.refresh_token_ttl_secs as i64);
532    // Propagate auth_time for DB consistency; this session-cookie path does not
533    // issue ID tokens, so auth_time is not surfaced to clients here.
534    db::store_refresh_token(
535        &state.db, user.id, None, &new_refresh_hash, expires_at,
536        &[], ua_truncated, Some(&ip), token_row.family_id, token_row.nonce.as_deref(),
537        token_row.auth_time,
538    ).await?;
539
540    // Mark the new token as just used (session was actively refreshed)
541    db::touch_refresh_token(&state.db, &new_refresh_hash).await?;
542
543    let jar = jar
544        .add(build_access_cookie(&state.cookie_names.access, &access_token, &state.config))
545        .add(build_refresh_cookie(&state.cookie_names.refresh, &new_refresh_raw, &state.config));
546
547    Ok((jar, StatusCode::OK))
548}
549
550/// POST /auth/logout — clear cookies + revoke refresh token
551#[utoipa::path(
552    post,
553    path = "/auth/logout",
554    tag = "auth",
555    responses(
556        (status = 204, description = "Logged out (cookies cleared)"),
557    )
558)]
559pub(crate) async fn auth_logout(
560    State(state): State<AppState>,
561    jar: CookieJar,
562) -> Result<(CookieJar, StatusCode), Error> {
563    if let Some(refresh) = jar.get(&state.cookie_names.refresh) {
564        let hash = jwt::hash_token(refresh.value());
565        // Look up user_id before deleting, for back-channel logout dispatch
566        if let Some(token_row) = db::find_refresh_token(&state.db, &hash).await? {
567            db::delete_refresh_token(&state.db, &hash).await?;
568            webhooks::dispatch_backchannel_logout(
569                &state.db, &state.keys, &state.config, &state.http_client,
570                token_row.user_id,
571            ).await;
572        }
573    }
574
575    let jar = jar
576        .remove(removal_cookie(&state.cookie_names.access, "/", &state.config))
577        .remove(removal_cookie(&state.cookie_names.refresh, "/auth", &state.config));
578
579    Ok((jar, StatusCode::NO_CONTENT))
580}
581
582/// POST /auth/logout-all — revoke all refresh tokens
583#[utoipa::path(
584    post,
585    path = "/auth/logout-all",
586    tag = "auth",
587    responses(
588        (status = 204, description = "All sessions revoked"),
589        (status = 401, description = "Not authenticated", body = ErrorBody),
590    )
591)]
592pub(crate) async fn auth_logout_all(
593    State(state): State<AppState>,
594    jar: CookieJar,
595) -> Result<(CookieJar, StatusCode), Error> {
596    let user = extract_user(&state, &jar)?;
597    let user_id = user.sub_uuid()?;
598
599    // Dispatch back-channel logout BEFORE deleting tokens (query needs active tokens)
600    webhooks::dispatch_backchannel_logout(
601        &state.db, &state.keys, &state.config, &state.http_client, user_id,
602    ).await;
603
604    db::delete_all_refresh_tokens(&state.db, user_id).await?;
605
606    let jar = jar
607        .remove(removal_cookie(&state.cookie_names.access, "/", &state.config))
608        .remove(removal_cookie(&state.cookie_names.refresh, "/auth", &state.config));
609
610    Ok((jar, StatusCode::NO_CONTENT))
611}
612
613// --- Session List/Revoke Endpoints ---
614
615#[derive(Serialize, utoipa::ToSchema)]
616pub(crate) struct SessionResponse {
617    id: String,
618    user_agent: Option<String>,
619    ip_address: Option<String>,
620    created_at: String,
621    last_used_at: Option<String>,
622    is_current: bool,
623}
624
625/// GET /auth/sessions — list active sessions for the current user
626#[utoipa::path(
627    get,
628    path = "/auth/sessions",
629    tag = "auth",
630    responses(
631        (status = 200, description = "Active sessions", body = Vec<SessionResponse>),
632        (status = 401, description = "Not authenticated", body = ErrorBody),
633    )
634)]
635pub(crate) async fn list_sessions(
636    State(state): State<AppState>,
637    jar: CookieJar,
638) -> Result<Json<Vec<SessionResponse>>, Error> {
639    let claims = extract_user(&state, &jar)?;
640    let user_id = claims.sub_uuid()?;
641
642    // Identify the current session by its refresh token
643    let current_token_hash = jar
644        .get(&state.cookie_names.refresh)
645        .map(|c| jwt::hash_token(c.value()));
646
647    let sessions = db::list_sessions(&state.db, user_id).await?;
648
649    // Look up the current session's ID from its token hash
650    let current_session_id = if let Some(ref hash) = current_token_hash {
651        db::find_refresh_token(&state.db, hash)
652            .await?
653            .map(|row| row.id)
654    } else {
655        None
656    };
657
658    let response: Vec<SessionResponse> = sessions
659        .into_iter()
660        .map(|s| SessionResponse {
661            is_current: current_session_id.is_some_and(|id| id == s.id),
662            id: s.id.to_string(),
663            user_agent: s.user_agent,
664            ip_address: s.ip_address,
665            created_at: s.created_at.to_rfc3339(),
666            last_used_at: s.last_used_at.map(|t| t.to_rfc3339()),
667        })
668        .collect();
669
670    Ok(Json(response))
671}
672
673/// DELETE /auth/sessions/{id} — revoke a specific session
674#[utoipa::path(
675    delete,
676    path = "/auth/sessions/{id}",
677    tag = "auth",
678    params(("id" = String, Path, description = "Session ID to revoke")),
679    responses(
680        (status = 204, description = "Session revoked"),
681        (status = 401, description = "Not authenticated", body = ErrorBody),
682        (status = 404, description = "Session not found", body = ErrorBody),
683    )
684)]
685pub(crate) async fn revoke_session(
686    State(state): State<AppState>,
687    Path(session_id): Path<String>,
688    jar: CookieJar,
689) -> Result<StatusCode, Error> {
690    let claims = extract_user(&state, &jar)?;
691    let user_id = claims.sub_uuid()?;
692
693    let session_uuid = uuid::Uuid::parse_str(&session_id)
694        .map_err(|_| Error::BadRequest("invalid session id".to_string()))?;
695
696    // Prevent revoking the current session (use /auth/logout for that)
697    if let Some(refresh) = jar.get(&state.cookie_names.refresh) {
698        let hash = jwt::hash_token(refresh.value());
699        if let Some(token_row) = db::find_refresh_token(&state.db, &hash).await? {
700            if token_row.id == session_uuid {
701                return Err(Error::BadRequest(
702                    "cannot revoke current session; use /auth/logout instead".to_string(),
703                ));
704            }
705        }
706    }
707
708    let deleted = db::revoke_session(&state.db, session_uuid, user_id).await?;
709    if !deleted {
710        return Err(Error::NotFound);
711    }
712
713    // Dispatch back-channel logout for the user
714    webhooks::dispatch_backchannel_logout(
715        &state.db, &state.keys, &state.config, &state.http_client, user_id,
716    ).await;
717
718    Ok(StatusCode::NO_CONTENT)
719}
720
721// --- Profile Endpoints ---
722
723/// GET /auth/me — current user profile
724#[utoipa::path(
725    get,
726    path = "/auth/me",
727    tag = "auth",
728    responses(
729        (status = 200, description = "Current user info", body = MeResponse),
730        (status = 401, description = "Not authenticated", body = ErrorBody),
731    )
732)]
733pub(crate) async fn auth_me(
734    State(state): State<AppState>,
735    jar: CookieJar,
736) -> Result<Json<MeResponse>, Error> {
737    let claims = extract_user(&state, &jar)?;
738    let user = db::find_user_by_id(&state.db, claims.sub_uuid()?)
739        .await?
740        .ok_or(Error::UserNotFound)?;
741
742    Ok(Json(user_to_me(&user)))
743}
744
745/// PATCH /auth/me — update display name
746#[utoipa::path(
747    patch,
748    path = "/auth/me",
749    tag = "auth",
750    request_body = UpdateDisplayNameRequest,
751    responses(
752        (status = 200, description = "Display name updated", body = MeResponse),
753        (status = 401, description = "Not authenticated", body = ErrorBody),
754    )
755)]
756pub(crate) async fn update_display_name(
757    State(state): State<AppState>,
758    jar: CookieJar,
759    Json(body): Json<UpdateDisplayNameRequest>,
760) -> Result<Json<MeResponse>, Error> {
761    if body.display_name.chars().count() > 200 {
762        return Err(Error::BadRequest("display name must be at most 200 characters".to_string()));
763    }
764
765    let claims = extract_user(&state, &jar)?;
766
767    // Treat empty string as clearing the display name
768    let display_name = if body.display_name.trim().is_empty() {
769        None
770    } else {
771        Some(body.display_name.as_str())
772    };
773
774    let user = db::update_user_display_name(
775        &state.db,
776        claims.sub_uuid()?,
777        display_name,
778    )
779    .await?;
780
781    webhooks::dispatch_event(
782        &state.db,
783        webhooks::USER_UPDATED,
784        serde_json::json!({ "user_id": user.id.to_string(), "display_name": user.display_name }),
785        state.config.webhooks.max_retry_attempts,
786    ).await;
787
788    Ok(Json(user_to_me(&user)))
789}
790
791/// PATCH /auth/me/username — change username
792#[utoipa::path(
793    patch,
794    path = "/auth/me/username",
795    tag = "auth",
796    request_body = UpdateUsernameRequest,
797    responses(
798        (status = 200, description = "Username updated", body = MeResponse),
799        (status = 400, description = "Invalid username or on cooldown", body = ErrorBody),
800        (status = 401, description = "Not authenticated", body = ErrorBody),
801        (status = 409, description = "Username taken", body = ErrorBody),
802    )
803)]
804pub(crate) async fn update_username(
805    State(state): State<AppState>,
806    jar: CookieJar,
807    Json(body): Json<UpdateUsernameRequest>,
808) -> Result<(CookieJar, Json<MeResponse>), Error> {
809    let claims = extract_user(&state, &jar)?;
810    let user_id = claims.sub_uuid()?;
811
812    if !state.config.usernames.allow_changes {
813        return Err(Error::BadRequest("username changes are disabled".to_string()));
814    }
815
816    validate_username(&body.username, &state.config, &state.username_regex)?;
817
818    // Check cooldown
819    if let Some(last_change) = db::last_username_change(&state.db, user_id).await? {
820        let cooldown = Duration::days(state.config.usernames.change_cooldown_days as i64);
821        let available_at = last_change + cooldown;
822        if Utc::now() < available_at {
823            return Err(Error::UsernameChangeCooldown { available_at });
824        }
825    }
826
827    // Check availability
828    if db::find_user_by_username(&state.db, &body.username).await?.is_some() {
829        return Err(Error::UsernameTaken);
830    }
831    if db::is_username_held(&state.db, &body.username, user_id).await? {
832        return Err(Error::UsernameTaken);
833    }
834
835    // Get current user for old username
836    let current_user = db::find_user_by_id(&state.db, user_id)
837        .await?
838        .ok_or(Error::UserNotFound)?;
839
840    // Record old username + update atomically
841    let hold_days = state.config.usernames.old_name_hold_days as i64;
842    let held_until = Utc::now() + Duration::days(hold_days);
843    let user = db::change_username(
844        &state.db,
845        user_id,
846        &current_user.username,
847        &body.username,
848        held_until,
849    )
850    .await
851    .map_err(|e| if riley_auth_core::error::is_unique_violation(&e) {
852        Error::UsernameTaken
853    } else {
854        e
855    })?;
856
857    // Re-issue access token with new username
858    let access_token = state.keys.sign_access_token(
859        &state.config.jwt,
860        &user.id.to_string(),
861        &user.username,
862        &user.role,
863        &state.config.jwt.issuer,
864    )?;
865
866    let jar = jar.add(build_access_cookie(&state.cookie_names.access, &access_token, &state.config));
867
868    webhooks::dispatch_event(
869        &state.db,
870        webhooks::USER_USERNAME_CHANGED,
871        serde_json::json!({
872            "user_id": user.id.to_string(),
873            "old_username": current_user.username,
874            "new_username": user.username,
875        }),
876        state.config.webhooks.max_retry_attempts,
877    ).await;
878
879    Ok((jar, Json(user_to_me(&user))))
880}
881
882/// DELETE /auth/me — delete account
883#[utoipa::path(
884    delete,
885    path = "/auth/me",
886    tag = "auth",
887    responses(
888        (status = 204, description = "Account deleted"),
889        (status = 400, description = "Cannot delete last admin", body = ErrorBody),
890        (status = 401, description = "Not authenticated", body = ErrorBody),
891    )
892)]
893pub(crate) async fn delete_account(
894    State(state): State<AppState>,
895    jar: CookieJar,
896) -> Result<(CookieJar, StatusCode), Error> {
897    let claims = extract_user(&state, &jar)?;
898    let user_id = claims.sub_uuid()?;
899
900    // Dispatch back-channel logout BEFORE soft delete (which deletes tokens)
901    webhooks::dispatch_backchannel_logout(
902        &state.db, &state.keys, &state.config, &state.http_client, user_id,
903    ).await;
904
905    match db::soft_delete_user(&state.db, user_id).await? {
906        db::DeleteUserResult::Deleted => {}
907        db::DeleteUserResult::LastAdmin => {
908            return Err(Error::BadRequest("cannot delete the last admin".to_string()));
909        }
910        db::DeleteUserResult::NotFound => {
911            return Err(Error::UserNotFound);
912        }
913    }
914
915    webhooks::dispatch_event(
916        &state.db,
917        webhooks::USER_DELETED,
918        serde_json::json!({ "user_id": user_id.to_string() }),
919        state.config.webhooks.max_retry_attempts,
920    ).await;
921
922    let jar = jar
923        .remove(removal_cookie(&state.cookie_names.access, "/", &state.config))
924        .remove(removal_cookie(&state.cookie_names.refresh, "/auth", &state.config));
925
926    Ok((jar, StatusCode::NO_CONTENT))
927}
928
929/// GET /auth/me/links — list linked providers
930#[utoipa::path(
931    get,
932    path = "/auth/me/links",
933    tag = "auth",
934    responses(
935        (status = 200, description = "Linked OAuth providers", body = Vec<LinkResponse>),
936        (status = 401, description = "Not authenticated", body = ErrorBody),
937    )
938)]
939pub(crate) async fn list_links(
940    State(state): State<AppState>,
941    jar: CookieJar,
942) -> Result<Json<Vec<LinkResponse>>, Error> {
943    let claims = extract_user(&state, &jar)?;
944    let links = db::find_oauth_links_by_user(&state.db, claims.sub_uuid()?).await?;
945
946    let response: Vec<LinkResponse> = links
947        .into_iter()
948        .map(|l| LinkResponse {
949            provider: l.provider,
950            provider_email: l.provider_email,
951            created_at: l.created_at.to_rfc3339(),
952        })
953        .collect();
954
955    Ok(Json(response))
956}
957
958// --- Provider Linking ---
959
960/// GET /auth/link/{provider} — start linking a new provider
961#[utoipa::path(
962    get,
963    path = "/auth/link/{provider}",
964    tag = "auth",
965    params(("provider" = String, Path, description = "OAuth provider name")),
966    responses(
967        (status = 302, description = "Redirect to provider for linking"),
968        (status = 401, description = "Not authenticated", body = ErrorBody),
969        (status = 404, description = "Unknown provider", body = ErrorBody),
970    )
971)]
972pub(crate) async fn link_redirect(
973    State(state): State<AppState>,
974    Path(provider_name): Path<String>,
975    jar: CookieJar,
976) -> Result<(CookieJar, Redirect), Error> {
977    // Must be authenticated
978    let _claims = extract_user(&state, &jar)?;
979
980    let provider = find_provider(&state.providers, &provider_name)?;
981    let callback_url = format!(
982        "{}/auth/link/{}/callback",
983        state.config.server.public_url, provider_name
984    );
985
986    let oauth_state = oauth::generate_state();
987    let (pkce_verifier, pkce_challenge) = oauth::generate_pkce();
988
989    let auth_url = oauth::build_auth_url(
990        provider,
991        &callback_url,
992        &oauth_state,
993        &pkce_challenge,
994    )?;
995
996    let jar = jar
997        .add(build_temp_cookie(&state.cookie_names.oauth_state, &oauth_state, &state.config))
998        .add(build_temp_cookie(&state.cookie_names.pkce, &pkce_verifier, &state.config));
999
1000    Ok((jar, Redirect::temporary(&auth_url)))
1001}
1002
1003/// GET /auth/link/{provider}/callback — complete linking
1004#[utoipa::path(
1005    get,
1006    path = "/auth/link/{provider}/callback",
1007    tag = "auth",
1008    params(("provider" = String, Path, description = "OAuth provider name")),
1009    responses(
1010        (status = 302, description = "Redirect after link callback"),
1011        (status = 400, description = "Invalid OAuth state or code", body = ErrorBody),
1012    )
1013)]
1014pub(crate) async fn link_callback(
1015    State(state): State<AppState>,
1016    Path(provider_name): Path<String>,
1017    Query(query): Query<CallbackQuery>,
1018    jar: CookieJar,
1019) -> Result<(CookieJar, Redirect), Error> {
1020    let claims = extract_user(&state, &jar)?;
1021    let user_id = claims.sub_uuid()?;
1022
1023    let provider = find_provider(&state.providers, &provider_name)?;
1024
1025    // Verify state
1026    let saved_state = jar
1027        .get(&state.cookie_names.oauth_state)
1028        .map(|c| c.value().to_string())
1029        .ok_or(Error::InvalidOAuthState)?;
1030
1031    if query.state.as_bytes().ct_eq(saved_state.as_bytes()).unwrap_u8() == 0 {
1032        return Err(Error::InvalidOAuthState);
1033    }
1034
1035    let pkce_verifier = jar
1036        .get(&state.cookie_names.pkce)
1037        .map(|c| c.value().to_string())
1038        .ok_or(Error::InvalidOAuthState)?;
1039
1040    let callback_url = format!(
1041        "{}/auth/link/{}/callback",
1042        state.config.server.public_url, provider_name
1043    );
1044
1045    let provider_token = oauth::exchange_code(
1046        provider,
1047        &query.code,
1048        &callback_url,
1049        &pkce_verifier,
1050        &state.oauth_client,
1051    )
1052    .await?;
1053
1054    let profile = oauth::fetch_profile(provider, &provider_token, &state.oauth_client).await?;
1055
1056    // Check if this provider account is already linked to someone
1057    if let Some(existing) = db::find_oauth_link(&state.db, &profile.provider, &profile.provider_id).await? {
1058        if existing.user_id == user_id {
1059            // Idempotent: already linked to this user (double-click / replay)
1060            let jar = jar
1061                .remove(removal_cookie(&state.cookie_names.oauth_state, "/", &state.config))
1062                .remove(removal_cookie(&state.cookie_names.pkce, "/", &state.config));
1063            let redirect_url = format!("{}/profile", state.config.server.public_url);
1064            return Ok((jar, Redirect::temporary(&redirect_url)));
1065        }
1066        return Err(Error::ProviderAlreadyLinked);
1067    }
1068
1069    // Create the link — if a concurrent request already created it,
1070    // treat as idempotent success for the same user.
1071    let link_created = match db::create_oauth_link(
1072        &state.db,
1073        user_id,
1074        &profile.provider,
1075        &profile.provider_id,
1076        profile.email.as_deref(),
1077        profile.email_verified,
1078    ).await {
1079        Ok(_) => true,
1080        Err(e) if riley_auth_core::error::is_unique_violation(&e) => {
1081            match db::find_oauth_link(&state.db, &profile.provider, &profile.provider_id).await? {
1082                Some(link) if link.user_id == user_id => false,
1083                _ => return Err(Error::ProviderAlreadyLinked),
1084            }
1085        }
1086        Err(e) => return Err(e),
1087    };
1088
1089    if link_created {
1090        webhooks::dispatch_event(
1091            &state.db,
1092            webhooks::LINK_CREATED,
1093            serde_json::json!({
1094                "user_id": user_id.to_string(),
1095                "provider": profile.provider,
1096            }),
1097            state.config.webhooks.max_retry_attempts,
1098        ).await;
1099    }
1100
1101    let jar = jar
1102        .remove(removal_cookie(&state.cookie_names.oauth_state, "/", &state.config))
1103        .remove(removal_cookie(&state.cookie_names.pkce, "/", &state.config));
1104
1105    let redirect_url = format!("{}/profile", state.config.server.public_url);
1106    Ok((jar, Redirect::temporary(&redirect_url)))
1107}
1108
1109/// DELETE /auth/link/{provider} — unlink provider
1110#[utoipa::path(
1111    delete,
1112    path = "/auth/link/{provider}",
1113    tag = "auth",
1114    params(("provider" = String, Path, description = "OAuth provider name")),
1115    responses(
1116        (status = 204, description = "Provider unlinked"),
1117        (status = 400, description = "Cannot unlink last provider", body = ErrorBody),
1118        (status = 401, description = "Not authenticated", body = ErrorBody),
1119    )
1120)]
1121pub(crate) async fn unlink_provider(
1122    State(state): State<AppState>,
1123    Path(provider_name): Path<String>,
1124    jar: CookieJar,
1125) -> Result<StatusCode, Error> {
1126    let claims = extract_user(&state, &jar)?;
1127    let user_id = claims.sub_uuid()?;
1128
1129    match db::delete_oauth_link_if_not_last(&state.db, user_id, &provider_name).await? {
1130        db::UnlinkResult::Deleted => {
1131            webhooks::dispatch_event(
1132                &state.db,
1133                webhooks::LINK_DELETED,
1134                serde_json::json!({
1135                    "user_id": user_id.to_string(),
1136                    "provider": provider_name,
1137                }),
1138                state.config.webhooks.max_retry_attempts,
1139            ).await;
1140            Ok(StatusCode::NO_CONTENT)
1141        }
1142        db::UnlinkResult::LastProvider => Err(Error::LastProvider),
1143        db::UnlinkResult::NotFound => Err(Error::NotFound),
1144    }
1145}
1146
1147// --- Helpers ---
1148
1149fn find_provider<'a>(
1150    providers: &'a [ResolvedProvider],
1151    name: &str,
1152) -> Result<&'a ResolvedProvider, Error> {
1153    providers
1154        .iter()
1155        .find(|p| p.name == name)
1156        .ok_or_else(|| Error::BadRequest(format!("unknown provider: {name}")))
1157}
1158
1159fn extract_user(state: &AppState, jar: &CookieJar) -> Result<jwt::Claims, Error> {
1160    let token = jar
1161        .get(&state.cookie_names.access)
1162        .map(|c| c.value().to_string())
1163        .ok_or(Error::Unauthenticated)?;
1164
1165    let data = state.keys.verify_session_token(&state.config.jwt, &token)?;
1166    Ok(data.claims)
1167}
1168
1169fn validate_username(username: &str, config: &Config, regex: &regex::Regex) -> Result<(), Error> {
1170    let rules = &config.usernames;
1171
1172    let char_count = username.chars().count();
1173    if char_count < rules.min_length {
1174        return Err(Error::InvalidUsername {
1175            reason: format!("must be at least {} characters", rules.min_length),
1176        });
1177    }
1178    if char_count > rules.max_length {
1179        return Err(Error::InvalidUsername {
1180            reason: format!("must be at most {} characters", rules.max_length),
1181        });
1182    }
1183
1184    if !regex.is_match(username) {
1185        return Err(Error::InvalidUsername {
1186            reason: "contains invalid characters".to_string(),
1187        });
1188    }
1189
1190    let check_name = username.to_lowercase();
1191
1192    for reserved in &rules.reserved {
1193        if check_name == reserved.to_lowercase() {
1194            return Err(Error::ReservedUsername);
1195        }
1196    }
1197
1198    Ok(())
1199}
1200
1201/// Extract the client IP address from the request.
1202///
1203/// Extract client IP as a string for storage in the database.
1204///
1205/// Delegates to `super::extract_client_ip` and formats as a String.
1206fn client_ip_string(headers: &HeaderMap, addr: SocketAddr, behind_proxy: bool) -> String {
1207    super::extract_client_ip(headers, Some(addr.ip()), behind_proxy)
1208        .map_or_else(|| addr.ip().to_string(), |ip| ip.to_string())
1209}
1210
1211/// Issue new access and refresh tokens, storing the refresh token in the DB.
1212/// Returns the cookie jar and the new refresh token hash (for touch_refresh_token).
1213async fn issue_tokens(
1214    state: &AppState,
1215    jar: CookieJar,
1216    user: &db::User,
1217    user_agent: Option<&str>,
1218    ip_address: Option<&str>,
1219) -> Result<(CookieJar, String), Error> {
1220    let access_token = state.keys.sign_access_token(
1221        &state.config.jwt,
1222        &user.id.to_string(),
1223        &user.username,
1224        &user.role,
1225        &state.config.jwt.issuer,
1226    )?;
1227
1228    // Truncate user_agent to prevent storage bloat from oversized headers.
1229    // Use floor_char_boundary to avoid panicking on multi-byte UTF-8 sequences.
1230    const MAX_USER_AGENT_BYTES: usize = 512;
1231    let ua_truncated = user_agent.map(|ua| &ua[..ua.floor_char_boundary(MAX_USER_AGENT_BYTES)]);
1232
1233    let (refresh_raw, refresh_hash) = jwt::generate_refresh_token();
1234    let family_id = uuid::Uuid::now_v7();
1235    let now = Utc::now();
1236    let expires_at = now + Duration::seconds(state.config.jwt.refresh_token_ttl_secs as i64);
1237    // auth_time = now is correct here: this runs immediately after OAuth callback,
1238    // so token-issue time ≈ authentication time. The OAuth provider path uses
1239    // auth_code.created_at instead (also ≈ authentication time from that flow).
1240    let auth_time = Some(now.timestamp());
1241    db::store_refresh_token(&state.db, user.id, None, &refresh_hash, expires_at, &[], ua_truncated, ip_address, family_id, None, auth_time).await?;
1242
1243    let jar = jar
1244        .add(build_access_cookie(&state.cookie_names.access, &access_token, &state.config))
1245        .add(build_refresh_cookie(&state.cookie_names.refresh, &refresh_raw, &state.config));
1246
1247    Ok((jar, refresh_hash))
1248}
1249
1250fn build_access_cookie(name: &str, token: &str, config: &Config) -> Cookie<'static> {
1251    let mut cookie = Cookie::new(name.to_string(), token.to_string());
1252    cookie.set_http_only(true);
1253    cookie.set_secure(true);
1254    cookie.set_same_site(SameSite::Lax);
1255    cookie.set_path("/");
1256    if let Some(ref domain) = config.server.cookie_domain {
1257        cookie.set_domain(domain.clone());
1258    }
1259    cookie.set_max_age(cookie::time::Duration::seconds(
1260        config.jwt.access_token_ttl_secs as i64,
1261    ));
1262    cookie
1263}
1264
1265fn build_refresh_cookie(name: &str, token: &str, config: &Config) -> Cookie<'static> {
1266    let mut cookie = Cookie::new(name.to_string(), token.to_string());
1267    cookie.set_http_only(true);
1268    cookie.set_secure(true);
1269    cookie.set_same_site(SameSite::Lax);
1270    cookie.set_path("/auth");
1271    if let Some(ref domain) = config.server.cookie_domain {
1272        cookie.set_domain(domain.clone());
1273    }
1274    cookie.set_max_age(cookie::time::Duration::seconds(
1275        config.jwt.refresh_token_ttl_secs as i64,
1276    ));
1277    cookie
1278}
1279
1280fn build_temp_cookie(name: &str, value: &str, config: &Config) -> Cookie<'static> {
1281    let mut cookie = Cookie::new(name.to_string(), value.to_string());
1282    cookie.set_http_only(true);
1283    cookie.set_secure(true);
1284    cookie.set_same_site(SameSite::Lax);
1285    cookie.set_path("/");
1286    if let Some(ref domain) = config.server.cookie_domain {
1287        cookie.set_domain(domain.clone());
1288    }
1289    cookie.set_max_age(cookie::time::Duration::minutes(15));
1290    cookie
1291}
1292
1293/// Build a removal cookie with matching path/domain so browsers clear the original.
1294fn removal_cookie(name: &str, path: &str, config: &Config) -> Cookie<'static> {
1295    let mut cookie = Cookie::new(name.to_string(), "");
1296    cookie.set_path(path.to_string());
1297    if let Some(ref domain) = config.server.cookie_domain {
1298        cookie.set_domain(domain.clone());
1299    }
1300    cookie
1301}
1302
1303fn user_to_me(user: &db::User) -> MeResponse {
1304    MeResponse {
1305        id: user.id.to_string(),
1306        username: user.username.clone(),
1307        display_name: user.display_name.clone(),
1308        avatar_url: user.avatar_url.clone(),
1309        role: user.role.clone(),
1310    }
1311}
1312
1313/// Setup token: short-lived JWT containing OAuth profile data.
1314/// Used to pass profile info between callback and setup endpoints.
1315/// The JWT signature protects integrity of all claims (profile, purpose, expiry).
1316#[derive(Serialize, Deserialize)]
1317struct SetupClaims {
1318    profile: oauth::OAuthProfile,
1319    exp: i64,
1320    iss: String,
1321    purpose: String,
1322}
1323
1324fn create_setup_token(
1325    keys: &Keys,
1326    config: &Config,
1327    profile: &oauth::OAuthProfile,
1328) -> Result<String, Error> {
1329    let claims = SetupClaims {
1330        profile: profile.clone(),
1331        exp: (Utc::now() + Duration::minutes(15)).timestamp(),
1332        iss: config.jwt.issuer.clone(),
1333        purpose: "setup".to_string(),
1334    };
1335
1336    let mut header = jsonwebtoken::Header::new(keys.active_algorithm());
1337    header.kid = Some(keys.active_kid().to_string());
1338    jsonwebtoken::encode(&header, &claims, &keys.encoding_key())
1339        .map_err(|e| Error::OAuth(format!("failed to create setup token: {e}")))
1340}
1341
1342fn decode_setup_token(
1343    keys: &Keys,
1344    config: &Config,
1345    token: &str,
1346) -> Result<oauth::OAuthProfile, Error> {
1347    let data = keys.verify_token::<SetupClaims>(&config.jwt, token)
1348        .map_err(|_| Error::InvalidToken)?;
1349
1350    if data.claims.purpose != "setup" {
1351        return Err(Error::InvalidToken);
1352    }
1353
1354    Ok(data.claims.profile)
1355}
1356
1357// Extension trait for Claims
1358trait ClaimsExt {
1359    fn sub_uuid(&self) -> Result<uuid::Uuid, Error>;
1360}
1361
1362impl ClaimsExt for jwt::Claims {
1363    fn sub_uuid(&self) -> Result<uuid::Uuid, Error> {
1364        self.sub.parse().map_err(|_| Error::InvalidToken)
1365    }
1366}