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#[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 .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 .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 .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 .route("/auth/link/{provider}", get(link_redirect).delete(unlink_provider))
82 .route("/auth/link/{provider}/callback", get(link_callback))
83}
84
85#[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#[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 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 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 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 let profile = oauth::fetch_profile(provider, &provider_token, &state.oauth_client).await?;
180
181 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 if let Some(link) = db::find_oauth_link(&state.db, &profile.provider, &profile.provider_id).await? {
188 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 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 if state.config.oauth.account_merge_policy == AccountMergePolicy::VerifiedEmail
212 && profile.email_verified
213 {
214 let verified_links: Vec<&db::OAuthLink> = matching_links.iter()
216 .filter(|l| l.email_verified)
217 .collect();
218
219 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 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 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 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#[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 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(&body.username, &state.config, &state.username_regex)?;
322
323 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 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 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 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 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#[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 let claims = extract_user(&state, &jar)?;
406 let user_id = claims.sub_uuid()?;
407
408 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 if let Some(existing) = db::find_oauth_link(&state.db, &profile.provider, &profile.provider_id).await? {
418 if existing.user_id == user_id {
419 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 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 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 let jar = jar.remove(removal_cookie(&state.cookie_names.setup, "/", &state.config));
450
451 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 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#[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 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 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 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 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 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#[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 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#[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 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#[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#[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 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 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#[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 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 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#[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#[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 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#[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 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 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 let current_user = db::find_user_by_id(&state.db, user_id)
837 .await?
838 .ok_or(Error::UserNotFound)?;
839
840 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 ¤t_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 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#[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 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#[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#[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 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#[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 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 if let Some(existing) = db::find_oauth_link(&state.db, &profile.provider, &profile.provider_id).await? {
1058 if existing.user_id == user_id {
1059 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 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#[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
1147fn 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: ®ex::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
1201fn 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
1211async 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 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 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
1293fn 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#[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
1357trait 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}