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
21const PROTOCOL_SCOPES: &[&str] = &["openid", "profile", "email"];
24
25#[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_id: Option<String>,
108 client_secret: Option<String>,
109}
110
111pub 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
122pub fn consent_router() -> Router<AppState> {
125 Router::new()
126 .route("/oauth/consent", get(consent).post(consent_decision))
127}
128
129fn redirect_error(
134 redirect_uri: &str,
135 error_code: &str,
136 description: &str,
137 state: Option<&str>,
138) -> Response {
139 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 (StatusCode::FOUND, [("location", url.to_string())]).into_response()
156}
157
158#[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 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 let redirect_uri = &query.redirect_uri;
203 let state_param = query.state.as_deref();
204
205 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 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 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; }
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 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 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 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 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 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 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 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); 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 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 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 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#[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 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 let consent_req = db::find_consent_request(&state.db, query.consent_id)
562 .await?
563 .ok_or(Error::NotFound)?;
564
565 if consent_req.user_id != user_id {
569 return Err(Error::NotFound);
570 }
571
572 let client = db::find_client_by_id(&state.db, consent_req.client_id)
574 .await?
575 .ok_or(Error::InvalidClient)?;
576
577 let mut scopes = Vec::new();
580 for s in &consent_req.scopes {
581 if s == "openid" {
582 continue; }
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()); 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#[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 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 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 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 let client = db::find_client_by_id(&state.db, consent_req.client_id)
684 .await?
685 .ok_or(Error::InvalidClient)?;
686
687 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 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#[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 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 let auth_code = db::consume_authorization_code(&state.db, &code_hash)
788 .await?
789 .ok_or(Error::InvalidAuthorizationCode)?;
790
791 if auth_code.redirect_uri != redirect_uri {
793 return Err(Error::InvalidGrant);
794 }
795
796 if auth_code.client_id != client.id {
798 return Err(Error::InvalidGrant);
799 }
800
801 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 let user = db::find_user_by_id(&state.db, auth_code.user_id)
819 .await?
820 .ok_or(Error::UserNotFound)?;
821
822 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 let id_token = if auth_code.scopes.iter().any(|s| s == "openid") {
861 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 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 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 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 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 let id_token = if effective_scopes.iter().any(|s| s == "openid") {
980 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#[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 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 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#[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 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 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 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 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
1142fn extract_client_credentials(
1147 headers: &HeaderMap,
1148 body_client_id: Option<&str>,
1149 body_client_secret: Option<&str>,
1150) -> Result<(String, String), Error> {
1151 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 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 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#[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
1209fn 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 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 let token_data = state
1241 .keys
1242 .verify_client_token(&state.config.jwt, bearer_token)?;
1243
1244 let claims = &token_data.claims;
1245
1246 db::find_client_by_client_id(&state.db, &claims.aud)
1248 .await?
1249 .ok_or(Error::InvalidToken)?;
1250
1251 let user_id: uuid::Uuid = claims.sub.parse().map_err(|_| Error::InvalidToken)?;
1253
1254 let user = db::find_user_by_id(&state.db, user_id)
1257 .await?
1258 .ok_or(Error::InvalidToken)?;
1259
1260 let scopes: BTreeSet<&str> = claims
1262 .scope
1263 .as_deref()
1264 .map(|s| s.split_whitespace().collect())
1265 .unwrap_or_default();
1266
1267 if !scopes.contains("openid") {
1269 return Err(Error::Forbidden);
1270 }
1271
1272 let mut response = serde_json::Map::new();
1274
1275 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 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}