1use std::time::{SystemTime, UNIX_EPOCH};
18
19use axum::Form;
20use axum::extract::{Path, Query, State};
21use axum::http::{HeaderMap, StatusCode, header};
22use axum::response::{Html, IntoResponse, Json, Redirect, Response};
23use serde::Deserialize;
24use serde_json::json;
25
26use crate::ctx::AuthCtx;
27
28use super::authorize::{
29 self as authz, AuthorizeRequest, AuthorizeValidation,
30};
31use super::consent::{ConsentPage, ConsentSubmission, scopes_already_granted};
32use super::introspect::{IntrospectRequest, IntrospectResponse};
33use super::revoke::RevokeRequest;
34use super::token::{
35 self as tok, TokenErrorBody, TokenRequest, TokenResponse, errors,
36};
37use super::types::{ConsentGrant, OidcSession};
38use super::userinfo::{self, AccessTokenClaims};
39
40const RESUME_COOKIE: &str = "assay_oidc_resume";
44
45pub async fn authorize_get(
52 State(ctx): State<AuthCtx>,
53 headers: HeaderMap,
54 Query(req): Query<AuthorizeRequest>,
55) -> Response {
56 let provider = match ctx.oidc_provider.as_ref() {
57 Some(p) => p,
58 None => return server_misconfigured("oidc_provider is not enabled"),
59 };
60
61 let client = match provider.clients.get(&req.client_id).await {
63 Ok(Some(c)) => c,
64 Ok(None) => {
65 return error_html(
66 StatusCode::BAD_REQUEST,
67 &format!("unknown client_id {:?}", req.client_id),
68 );
69 }
70 Err(e) => return server_error_html(&format!("client lookup failed: {e}")),
71 };
72
73 match authz::validate(&req, &client) {
75 AuthorizeValidation::Ok { scopes } => {
76 authorize_post_validate(ctx.clone(), &headers, req, client, scopes).await
77 }
78 AuthorizeValidation::Fatal { reason } => {
79 error_html(StatusCode::BAD_REQUEST, &reason)
80 }
81 AuthorizeValidation::Redirect { error, description } => {
82 Redirect::to(&authz::redirect_with_error(
83 &req.redirect_uri,
84 error,
85 &description,
86 req.state.as_deref(),
87 ))
88 .into_response()
89 }
90 }
91}
92
93async fn authorize_post_validate(
97 ctx: AuthCtx,
98 headers: &HeaderMap,
99 req: AuthorizeRequest,
100 client: super::types::OidcClient,
101 scopes: Vec<String>,
102) -> Response {
103 let session_id = parse_cookie(headers, crate::session::SESSION_COOKIE);
105 let session = match session_id {
106 Some(sid) => match ctx.sessions.get(&sid).await {
107 Ok(Some(s)) if s.expires_at > now_secs() => Some(s),
108 _ => None,
109 },
110 None => None,
111 };
112
113 let Some(session) = session else {
115 let original = rebuild_authorize_url(&ctx, &req);
116 return Redirect::to(&authz::return_to_for(&original)).into_response();
117 };
118
119 let provider = match ctx.oidc_provider.as_ref() {
121 Some(p) => p,
122 None => return server_misconfigured("oidc_provider is not enabled"),
123 };
124
125 let needs_consent = if !client.require_consent {
126 false
127 } else {
128 match provider.consents.get(&session.user_id, &client.client_id).await {
131 Ok(Some(grant)) => !scopes_already_granted(&scopes, &grant.scopes),
132 _ => true,
133 }
134 };
135
136 if needs_consent {
137 let resume = encode_resume(&req);
138 let page = ConsentPage {
139 client_name: &client.name,
140 issuer: &provider.issuer,
141 scopes: &scopes,
142 csrf_token: &session.csrf_token,
143 resume_token: &resume,
144 };
145 let mut response = Html(page.render_html()).into_response();
146 if let Ok(value) = format!(
149 "{}={}; Path=/; HttpOnly; SameSite=Lax",
150 RESUME_COOKIE, resume
151 )
152 .parse()
153 {
154 response
155 .headers_mut()
156 .append(header::SET_COOKIE, value);
157 }
158 return response;
159 }
160
161 issue_authorization_code(&ctx, req, &session.user_id, scopes).await
163}
164
165async fn issue_authorization_code(
168 ctx: &AuthCtx,
169 req: AuthorizeRequest,
170 user_id: &str,
171 scopes: Vec<String>,
172) -> Response {
173 let provider = match ctx.oidc_provider.as_ref() {
174 Some(p) => p,
175 None => return server_misconfigured("oidc_provider is not enabled"),
176 };
177 let code = authz::build_code(user_id, &req, scopes);
178 if let Err(e) = provider.codes.create(&code).await {
179 return server_error_html(&format!("persist authorization code: {e}"));
180 }
181 let redirect = authz::redirect_with_code(
182 &req.redirect_uri,
183 &code.code,
184 req.state.as_deref(),
185 );
186 Redirect::to(&redirect).into_response()
187}
188
189fn rebuild_authorize_url(ctx: &AuthCtx, req: &AuthorizeRequest) -> String {
192 let issuer = ctx
193 .oidc_provider
194 .as_ref()
195 .map(|p| p.issuer.as_str())
196 .unwrap_or("");
197 let mut url = format!("{issuer}/authorize?response_type={}", url_encode(&req.response_type));
198 url.push_str(&format!("&client_id={}", url_encode(&req.client_id)));
199 url.push_str(&format!("&redirect_uri={}", url_encode(&req.redirect_uri)));
200 url.push_str(&format!("&scope={}", url_encode(&req.scope)));
201 if let Some(s) = &req.state {
202 url.push_str(&format!("&state={}", url_encode(s)));
203 }
204 if let Some(n) = &req.nonce {
205 url.push_str(&format!("&nonce={}", url_encode(n)));
206 }
207 if let Some(c) = &req.code_challenge {
208 url.push_str(&format!("&code_challenge={}", url_encode(c)));
209 }
210 if let Some(m) = &req.code_challenge_method {
211 url.push_str(&format!("&code_challenge_method={}", url_encode(m)));
212 }
213 url
214}
215
216pub async fn consent_post(
225 State(ctx): State<AuthCtx>,
226 headers: HeaderMap,
227 Form(submission): Form<ConsentSubmission>,
228) -> Response {
229 let provider = match ctx.oidc_provider.as_ref() {
230 Some(p) => p,
231 None => return server_misconfigured("oidc_provider is not enabled"),
232 };
233
234 let resume = match parse_cookie(&headers, RESUME_COOKIE) {
237 Some(c) => c,
238 None => {
239 return error_html(
240 StatusCode::BAD_REQUEST,
241 "consent flow has no resume token (cookie missing)",
242 );
243 }
244 };
245 let req = match decode_resume(&resume) {
246 Some(r) => r,
247 None => {
248 return error_html(
249 StatusCode::BAD_REQUEST,
250 "consent resume payload is malformed",
251 );
252 }
253 };
254
255 let session = match parse_cookie(&headers, crate::session::SESSION_COOKIE) {
257 Some(sid) => ctx.sessions.get(&sid).await.ok().flatten(),
258 None => None,
259 };
260 let Some(session) = session else {
261 return error_html(StatusCode::UNAUTHORIZED, "no active session");
262 };
263 if session.csrf_token != submission.csrf_token {
264 return error_html(StatusCode::FORBIDDEN, "csrf mismatch");
265 }
266
267 let client = match provider.clients.get(&req.client_id).await {
270 Ok(Some(c)) => c,
271 _ => return error_html(StatusCode::BAD_REQUEST, "unknown client_id"),
272 };
273 let scopes: Vec<String> = req
274 .scope
275 .split_whitespace()
276 .map(|s| s.to_string())
277 .collect();
278
279 if !submission.allowed() {
280 let redirect = authz::redirect_with_error(
282 &req.redirect_uri,
283 "access_denied",
284 "user denied consent",
285 req.state.as_deref(),
286 );
287 return Redirect::to(&redirect).into_response();
288 }
289
290 let grant = ConsentGrant {
292 user_id: session.user_id.clone(),
293 client_id: client.client_id.clone(),
294 scopes: scopes.clone(),
295 granted_at: now_secs(),
296 };
297 if let Err(e) = provider.consents.upsert(&grant).await {
298 return server_error_html(&format!("persist consent: {e}"));
299 }
300
301 issue_authorization_code(&ctx, req, &session.user_id, scopes).await
302}
303
304pub async fn token_post(
311 State(ctx): State<AuthCtx>,
312 headers: HeaderMap,
313 Form(req): Form<TokenRequest>,
314) -> Response {
315 let _provider = match ctx.oidc_provider.as_ref() {
316 Some(p) => p,
317 None => return server_misconfigured("oidc_provider is not enabled"),
318 };
319
320 let client = match authenticate_client(&ctx, &headers, &req).await {
322 Ok(c) => c,
323 Err((status, body)) => return (status, Json(body)).into_response(),
324 };
325
326 match req.grant_type.as_str() {
327 "authorization_code" => grant_authorization_code(&ctx, &client, &req).await,
328 "refresh_token" => grant_refresh(&ctx, &client, &req).await,
329 other => token_err(
330 StatusCode::BAD_REQUEST,
331 errors::UNSUPPORTED_GRANT_TYPE,
332 Some(format!("grant_type {other:?} is not supported")),
333 ),
334 }
335}
336
337async fn authenticate_client(
341 ctx: &AuthCtx,
342 headers: &HeaderMap,
343 req: &TokenRequest,
344) -> Result<super::types::OidcClient, (StatusCode, TokenErrorBody)> {
345 let provider = ctx
346 .oidc_provider
347 .as_ref()
348 .ok_or_else(|| (StatusCode::INTERNAL_SERVER_ERROR, err_body(errors::SERVER_ERROR, None)))?;
349
350 let basic = headers
352 .get(header::AUTHORIZATION)
353 .and_then(|v| v.to_str().ok())
354 .and_then(|s| s.strip_prefix("Basic "))
355 .or_else(|| {
356 headers
357 .get(header::AUTHORIZATION)
358 .and_then(|v| v.to_str().ok())
359 .and_then(|s| s.strip_prefix("basic "))
360 })
361 .and_then(|enc| data_encoding::BASE64.decode(enc.as_bytes()).ok())
362 .and_then(|bytes| String::from_utf8(bytes).ok())
363 .and_then(|s| {
364 let (id, secret) = s.split_once(':')?;
365 Some((id.to_string(), secret.to_string()))
366 });
367
368 let (client_id, presented_secret) = match (basic, &req.client_id) {
369 (Some((id, secret)), _) => (id, Some(secret)),
370 (None, Some(id)) => (id.clone(), req.client_secret.clone()),
371 (None, None) => {
372 return Err((
373 StatusCode::UNAUTHORIZED,
374 err_body(errors::INVALID_CLIENT, Some("client_id missing".into())),
375 ));
376 }
377 };
378
379 let client = match provider.clients.get(&client_id).await {
380 Ok(Some(c)) => c,
381 _ => {
382 return Err((
383 StatusCode::UNAUTHORIZED,
384 err_body(errors::INVALID_CLIENT, Some("unknown client".into())),
385 ));
386 }
387 };
388
389 match client.token_endpoint_auth_method {
390 super::types::TokenAuthMethod::None => {
391 Ok(client)
393 }
394 super::types::TokenAuthMethod::ClientSecretBasic
395 | super::types::TokenAuthMethod::ClientSecretPost => {
396 let presented = presented_secret
397 .as_deref()
398 .map(|s| s.to_string())
399 .unwrap_or_default();
400 let stored = client.client_secret_hash.as_deref().unwrap_or("");
401 if !verify_client_secret(&presented, stored) {
402 return Err((
403 StatusCode::UNAUTHORIZED,
404 err_body(errors::INVALID_CLIENT, Some("bad secret".into())),
405 ));
406 }
407 Ok(client)
408 }
409 super::types::TokenAuthMethod::PrivateKeyJwt => {
410 Err((
412 StatusCode::BAD_REQUEST,
413 err_body(
414 errors::INVALID_CLIENT,
415 Some("private_key_jwt not yet supported".into()),
416 ),
417 ))
418 }
419 }
420}
421
422fn verify_client_secret(presented: &str, stored: &str) -> bool {
427 if stored.starts_with("$argon2") {
428 let hasher = crate::password::PasswordHasher::default();
429 return hasher.verify(presented, stored).unwrap_or(false);
430 }
431 let a = presented.as_bytes();
433 let b = stored.as_bytes();
434 if a.len() != b.len() {
435 return false;
436 }
437 let mut diff = 0u8;
438 for (x, y) in a.iter().zip(b.iter()) {
439 diff |= x ^ y;
440 }
441 diff == 0
442}
443
444async fn grant_authorization_code(
448 ctx: &AuthCtx,
449 client: &super::types::OidcClient,
450 req: &TokenRequest,
451) -> Response {
452 let provider = match ctx.oidc_provider.as_ref() {
453 Some(p) => p,
454 None => return token_err(StatusCode::INTERNAL_SERVER_ERROR, errors::SERVER_ERROR, None),
455 };
456 let Some(code_str) = req.code.as_deref() else {
457 return token_err(
458 StatusCode::BAD_REQUEST,
459 errors::INVALID_REQUEST,
460 Some("code is required".into()),
461 );
462 };
463 let consumed = match provider.codes.consume(code_str).await {
464 Ok(Some(c)) => c,
465 Ok(None) => {
466 return token_err(
467 StatusCode::BAD_REQUEST,
468 errors::INVALID_GRANT,
469 Some("code is unknown or already used".into()),
470 );
471 }
472 Err(e) => {
473 return token_err(
474 StatusCode::INTERNAL_SERVER_ERROR,
475 errors::SERVER_ERROR,
476 Some(format!("consume code: {e}")),
477 );
478 }
479 };
480 if consumed.expires_at <= now_secs() {
481 return token_err(
482 StatusCode::BAD_REQUEST,
483 errors::INVALID_GRANT,
484 Some("code expired".into()),
485 );
486 }
487 if consumed.client_id != client.client_id {
488 return token_err(
489 StatusCode::BAD_REQUEST,
490 errors::INVALID_GRANT,
491 Some("code does not belong to this client".into()),
492 );
493 }
494 if let Some(redirect) = &req.redirect_uri
495 && redirect != &consumed.redirect_uri {
496 return token_err(
497 StatusCode::BAD_REQUEST,
498 errors::INVALID_GRANT,
499 Some("redirect_uri mismatch".into()),
500 );
501 }
502 if !consumed.code_challenge.is_empty() {
504 let verifier = req.code_verifier.as_deref().unwrap_or("");
505 if !tok::verify_pkce_s256(verifier, &consumed.code_challenge) {
506 return token_err(
507 StatusCode::BAD_REQUEST,
508 errors::INVALID_GRANT,
509 Some("PKCE verifier mismatch".into()),
510 );
511 }
512 }
513
514 issue_token_pair(
515 ctx,
516 client,
517 &consumed.user_id,
518 &consumed.scopes,
519 consumed.nonce.as_deref(),
520 )
521 .await
522}
523
524async fn grant_refresh(
528 ctx: &AuthCtx,
529 client: &super::types::OidcClient,
530 req: &TokenRequest,
531) -> Response {
532 let provider = match ctx.oidc_provider.as_ref() {
533 Some(p) => p,
534 None => return token_err(StatusCode::INTERNAL_SERVER_ERROR, errors::SERVER_ERROR, None),
535 };
536 let Some(presented) = req.refresh_token.as_deref() else {
537 return token_err(
538 StatusCode::BAD_REQUEST,
539 errors::INVALID_REQUEST,
540 Some("refresh_token is required".into()),
541 );
542 };
543 let hash = tok::hash_refresh_token(presented);
544 let row = match provider.refresh.get(&hash).await {
545 Ok(Some(r)) => r,
546 Ok(None) => {
547 return token_err(
548 StatusCode::BAD_REQUEST,
549 errors::INVALID_GRANT,
550 Some("refresh_token unknown".into()),
551 );
552 }
553 Err(e) => {
554 return token_err(
555 StatusCode::INTERNAL_SERVER_ERROR,
556 errors::SERVER_ERROR,
557 Some(format!("refresh lookup: {e}")),
558 );
559 }
560 };
561 if row.revoked {
562 let _ = provider.refresh.revoke_for_user(&row.user_id).await;
564 return token_err(
565 StatusCode::BAD_REQUEST,
566 errors::INVALID_GRANT,
567 Some("refresh_token revoked (replay detected)".into()),
568 );
569 }
570 if row.expires_at <= now_secs() {
571 return token_err(
572 StatusCode::BAD_REQUEST,
573 errors::INVALID_GRANT,
574 Some("refresh_token expired".into()),
575 );
576 }
577 if row.client_id != client.client_id {
578 return token_err(
579 StatusCode::BAD_REQUEST,
580 errors::INVALID_GRANT,
581 Some("refresh_token client mismatch".into()),
582 );
583 }
584 if let Err(e) = provider.refresh.revoke(&hash).await {
585 return token_err(
586 StatusCode::INTERNAL_SERVER_ERROR,
587 errors::SERVER_ERROR,
588 Some(format!("revoke old refresh: {e}")),
589 );
590 }
591 issue_token_pair(ctx, client, &row.user_id, &row.scopes, None).await
592}
593
594async fn issue_token_pair(
599 ctx: &AuthCtx,
600 client: &super::types::OidcClient,
601 user_id: &str,
602 scopes: &[String],
603 nonce: Option<&str>,
604) -> Response {
605 let provider = match ctx.oidc_provider.as_ref() {
606 Some(p) => p,
607 None => return token_err(StatusCode::INTERNAL_SERVER_ERROR, errors::SERVER_ERROR, None),
608 };
609 let user = match ctx.users.get_user_by_id(user_id).await {
610 Ok(Some(u)) => Some(u),
611 _ => None,
612 };
613 let email = user.as_ref().and_then(|u| u.email.clone());
614 let email_verified = user.as_ref().map(|u| u.email_verified).unwrap_or(false);
615 let display_name = user.as_ref().and_then(|u| u.display_name.clone());
616
617 let sid = tok::mint_sid();
618
619 let id_claims = tok::build_id_token_claims(
620 &provider.issuer,
621 user_id,
622 &client.client_id,
623 &sid,
624 scopes,
625 nonce,
626 email.as_deref(),
627 email_verified,
628 display_name.as_deref(),
629 );
630 let access_claims =
631 tok::build_access_token_claims(&provider.issuer, user_id, &client.client_id, &sid, scopes);
632
633 let jwt = match ctx.jwt.as_ref() {
634 Some(j) => j,
635 None => {
636 return token_err(
637 StatusCode::INTERNAL_SERVER_ERROR,
638 errors::SERVER_ERROR,
639 Some("jwt not configured".into()),
640 );
641 }
642 };
643 let id_token = match jwt.issue(&id_claims) {
644 Ok(t) => t,
645 Err(e) => {
646 return token_err(
647 StatusCode::INTERNAL_SERVER_ERROR,
648 errors::SERVER_ERROR,
649 Some(format!("sign id_token: {e}")),
650 );
651 }
652 };
653 let access_token = match jwt.issue(&access_claims) {
654 Ok(t) => t,
655 Err(e) => {
656 return token_err(
657 StatusCode::INTERNAL_SERVER_ERROR,
658 errors::SERVER_ERROR,
659 Some(format!("sign access_token: {e}")),
660 );
661 }
662 };
663
664 let issue_refresh = client.allows_grant("refresh_token")
667 || scopes.iter().any(|s| s == "offline_access");
668 let refresh_token = if issue_refresh {
669 let plaintext = tok::mint_refresh_token();
670 let row = tok::build_refresh_row(user_id, &client.client_id, scopes, &plaintext);
671 if let Err(e) = provider.refresh.create(&row).await {
672 return token_err(
673 StatusCode::INTERNAL_SERVER_ERROR,
674 errors::SERVER_ERROR,
675 Some(format!("persist refresh: {e}")),
676 );
677 }
678 Some(plaintext)
679 } else {
680 None
681 };
682
683 let oidc_session = OidcSession {
685 sid: sid.clone(),
686 user_id: user_id.to_string(),
687 client_id: client.client_id.clone(),
688 assay_session_id: None,
689 issued_at: now_secs(),
690 backchannel_logout_uri: client.backchannel_logout_uri.clone(),
691 };
692 if let Err(e) = provider.sessions.create(&oidc_session).await {
693 tracing::warn!(?e, "failed to record SSO session — continuing");
694 }
695
696 let response = TokenResponse {
697 access_token,
698 token_type: "Bearer",
699 expires_in: tok::ACCESS_TOKEN_LIFETIME_SECS as i64,
700 id_token,
701 refresh_token,
702 scope: scopes.join(" "),
703 };
704 (StatusCode::OK, Json(response)).into_response()
705}
706
707pub async fn userinfo_get(State(ctx): State<AuthCtx>, headers: HeaderMap) -> Response {
713 let bearer = headers
714 .get(header::AUTHORIZATION)
715 .and_then(|v| v.to_str().ok())
716 .and_then(userinfo::parse_bearer);
717 let Some(token) = bearer else {
718 return (StatusCode::UNAUTHORIZED, Json(json!({"error": "invalid_token"})))
719 .into_response();
720 };
721 let jwt = match ctx.jwt.as_ref() {
722 Some(j) => j,
723 None => {
724 return (
725 StatusCode::INTERNAL_SERVER_ERROR,
726 Json(json!({"error": "server_error"})),
727 )
728 .into_response();
729 }
730 };
731 let data = match jwt.verify::<AccessTokenClaims>(token) {
732 Ok(d) => d,
733 Err(_) => {
734 return (StatusCode::UNAUTHORIZED, Json(json!({"error": "invalid_token"})))
735 .into_response();
736 }
737 };
738 let user = match ctx.users.get_user_by_id(&data.claims.sub).await {
739 Ok(Some(u)) => u,
740 _ => {
741 return (StatusCode::UNAUTHORIZED, Json(json!({"error": "invalid_token"})))
742 .into_response();
743 }
744 };
745 let claims = userinfo::build_userinfo(&user, &data.claims.scopes());
746 (StatusCode::OK, Json(claims)).into_response()
747}
748
749pub async fn revoke_post(
755 State(ctx): State<AuthCtx>,
756 Form(req): Form<RevokeRequest>,
757) -> Response {
758 if let Some(provider) = ctx.oidc_provider.as_ref() {
759 let hash = tok::hash_refresh_token(&req.token);
761 let _ = provider.refresh.revoke(&hash).await;
762 }
763 StatusCode::OK.into_response()
764}
765
766pub async fn introspect_post(
774 State(ctx): State<AuthCtx>,
775 headers: HeaderMap,
776 Form(body): Form<IntrospectRequest>,
777) -> Response {
778 let synth = TokenRequest {
781 grant_type: String::new(),
782 ..Default::default()
783 };
784 if authenticate_client(&ctx, &headers, &synth).await.is_err() {
785 return (StatusCode::UNAUTHORIZED, Json(IntrospectResponse::inactive()))
786 .into_response();
787 }
788
789 let jwt = match ctx.jwt.as_ref() {
790 Some(j) => j,
791 None => return (StatusCode::OK, Json(IntrospectResponse::inactive())).into_response(),
792 };
793
794 if let Ok(data) = jwt.verify::<AccessTokenClaims>(&body.token) {
796 let resp = IntrospectResponse {
797 active: true,
798 client_id: Some(data.claims.client_id.clone()),
799 username: Some(data.claims.sub.clone()),
800 scope: Some(data.claims.scope.clone()),
801 exp: Some(data.claims.exp),
802 sub: Some(data.claims.sub.clone()),
803 aud: Some(data.claims.aud.clone()),
804 iat: Some(data.claims.iat),
805 token_type: Some("Bearer".into()),
806 };
807 return (StatusCode::OK, Json(resp)).into_response();
808 }
809
810 if let Some(provider) = ctx.oidc_provider.as_ref() {
812 let hash = tok::hash_refresh_token(&body.token);
813 if let Ok(Some(row)) = provider.refresh.get(&hash).await
814 && !row.revoked && row.expires_at > now_secs() {
815 let resp = IntrospectResponse {
816 active: true,
817 client_id: Some(row.client_id.clone()),
818 username: Some(row.user_id.clone()),
819 scope: Some(row.scopes.join(" ")),
820 exp: Some(row.expires_at as i64),
821 sub: Some(row.user_id),
822 aud: Some(row.client_id),
823 iat: Some(row.issued_at as i64),
824 token_type: Some("Bearer".into()),
825 };
826 return (StatusCode::OK, Json(resp)).into_response();
827 }
828 }
829
830 (StatusCode::OK, Json(IntrospectResponse::inactive())).into_response()
831}
832
833#[derive(Deserialize)]
839pub struct LogoutQuery {
840 pub id_token_hint: Option<String>,
841 pub post_logout_redirect_uri: Option<String>,
842 pub state: Option<String>,
843}
844
845pub async fn logout_get(
847 State(ctx): State<AuthCtx>,
848 headers: HeaderMap,
849 Query(q): Query<LogoutQuery>,
850) -> Response {
851 if let Some(sid) = parse_cookie(&headers, crate::session::SESSION_COOKIE) {
852 let _ = ctx.sessions.delete(&sid).await;
853 if let Some(provider) = ctx.oidc_provider.as_ref() {
856 if let Ok(rows) = provider.sessions.list_by_assay_session(&sid).await {
857 for row in rows {
858 if let Some(uri) = row.backchannel_logout_uri {
859 tokio::spawn(async move {
862 let client = reqwest::Client::new();
863 let _ = client
864 .post(&uri)
865 .form(&[("logout_token", "stub")])
866 .timeout(std::time::Duration::from_secs(5))
867 .send()
868 .await;
869 });
870 }
871 }
872 }
873 let _ = provider.sessions.delete_by_assay_session(&sid).await;
874 }
875 }
876 let _ = q.id_token_hint;
877 let _ = q.state;
878 let target = q.post_logout_redirect_uri.unwrap_or_else(|| "/".to_string());
879 let mut response = Redirect::to(&target).into_response();
880 if let Ok(value) = format!(
882 "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
883 crate::session::SESSION_COOKIE
884 )
885 .parse()
886 {
887 response.headers_mut().append(header::SET_COOKIE, value);
888 }
889 response
890}
891
892#[derive(Deserialize)]
898pub struct UpstreamStartQuery {
899 pub return_to: Option<String>,
900}
901
902pub async fn upstream_start(
904 State(ctx): State<AuthCtx>,
905 Path(slug): Path<String>,
906 Query(q): Query<UpstreamStartQuery>,
907) -> Response {
908 let provider = match ctx.oidc_provider.as_ref() {
909 Some(p) => p,
910 None => return server_misconfigured("oidc_provider is not enabled"),
911 };
912 let registry = match ctx.oidc.as_ref() {
913 Some(r) => r,
914 None => return server_misconfigured("oidc client registry is not enabled"),
915 };
916 let started = match super::federation::start_upstream_login(
917 registry,
918 &provider.upstream_states,
919 &slug,
920 q.return_to,
921 )
922 .await
923 {
924 Ok(s) => s,
925 Err(e) => {
926 return error_html(StatusCode::BAD_REQUEST, &format!("upstream start: {e}"));
927 }
928 };
929 Redirect::to(&started.redirect_url).into_response()
930}
931
932#[derive(Deserialize)]
934pub struct UpstreamCallbackQuery {
935 pub code: String,
936 pub state: String,
937}
938
939pub async fn upstream_callback(
941 State(ctx): State<AuthCtx>,
942 Path(_slug): Path<String>,
943 Query(q): Query<UpstreamCallbackQuery>,
944) -> Response {
945 let provider = match ctx.oidc_provider.as_ref() {
946 Some(p) => p,
947 None => return server_misconfigured("oidc_provider is not enabled"),
948 };
949 let registry = match ctx.oidc.as_ref() {
950 Some(r) => r,
951 None => return server_misconfigured("oidc client registry is not enabled"),
952 };
953 let info = match super::federation::complete_upstream_login(
954 registry,
955 &provider.upstream_states,
956 &q.code,
957 &q.state,
958 )
959 .await
960 {
961 Ok(i) => i,
962 Err(e) => {
963 return error_html(StatusCode::BAD_REQUEST, &format!("upstream complete: {e}"));
964 }
965 };
966
967 let user = match ctx
969 .users
970 .get_user_by_upstream(&info.provider_slug, &info.subject)
971 .await
972 {
973 Ok(Some(u)) => u,
974 Ok(None) => {
975 let id = format!(
978 "usr_{}",
979 data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<16>())
980 );
981 let user = crate::store::User {
982 id: id.clone(),
983 email: info.email.clone(),
984 email_verified: info.email_verified,
985 display_name: info.display_name.clone(),
986 created_at: now_secs(),
987 };
988 if let Err(e) = ctx.users.create_user(&user).await {
989 return server_error_html(&format!("create user: {e}"));
990 }
991 if let Err(e) = ctx
992 .users
993 .link_upstream(&id, &info.provider_slug, &info.subject)
994 .await
995 {
996 return server_error_html(&format!("link upstream: {e}"));
997 }
998 user
999 }
1000 Err(e) => return server_error_html(&format!("upstream user lookup: {e}")),
1001 };
1002
1003 let mgr = crate::session::SessionManager::with_default_duration(ctx.sessions.clone());
1005 let session = match mgr.create(&user.id).await {
1006 Ok(s) => s,
1007 Err(e) => return server_error_html(&format!("create session: {e}")),
1008 };
1009 let mut response = Redirect::to(info.return_to.as_deref().unwrap_or("/")).into_response();
1010 let cookie = crate::session::cookie_for(&session, &provider.public_url);
1011 if let Ok(value) = cookie.to_string().parse() {
1012 response.headers_mut().append(header::SET_COOKIE, value);
1013 }
1014 response
1015}
1016
1017fn encode_resume(req: &AuthorizeRequest) -> String {
1025 let json = serde_json::to_vec(req).unwrap_or_default();
1026 data_encoding::BASE64URL_NOPAD.encode(&json)
1027}
1028
1029fn decode_resume(s: &str) -> Option<AuthorizeRequest> {
1030 let bytes = data_encoding::BASE64URL_NOPAD.decode(s.as_bytes()).ok()?;
1031 serde_json::from_slice(&bytes).ok()
1032}
1033
1034pub(crate) fn parse_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
1036 let raw = headers.get(header::COOKIE)?.to_str().ok()?;
1037 for kv in raw.split(';') {
1038 let kv = kv.trim();
1039 if let Some((k, v)) = kv.split_once('=')
1040 && k == name {
1041 return Some(v.to_string());
1042 }
1043 }
1044 None
1045}
1046
1047fn token_err(status: StatusCode, code: &str, desc: Option<String>) -> Response {
1049 (status, Json(err_body(code, desc))).into_response()
1050}
1051
1052fn err_body(code: &str, desc: Option<String>) -> TokenErrorBody {
1053 TokenErrorBody {
1054 error: code.to_string(),
1055 error_description: desc,
1056 }
1057}
1058
1059fn error_html(status: StatusCode, message: &str) -> Response {
1062 let body = format!("<!doctype html><body><h1>Error</h1><pre>{message}</pre></body>");
1063 (status, Html(body)).into_response()
1064}
1065
1066fn server_error_html(message: &str) -> Response {
1067 error_html(StatusCode::INTERNAL_SERVER_ERROR, message)
1068}
1069
1070fn server_misconfigured(reason: &str) -> Response {
1071 error_html(StatusCode::INTERNAL_SERVER_ERROR, reason)
1072}
1073
1074fn now_secs() -> f64 {
1075 SystemTime::now()
1076 .duration_since(UNIX_EPOCH)
1077 .unwrap_or_default()
1078 .as_secs_f64()
1079}
1080
1081fn url_encode(s: &str) -> String {
1086 let mut out = String::with_capacity(s.len());
1087 for byte in s.bytes() {
1088 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
1089 out.push(byte as char);
1090 } else {
1091 out.push_str(&format!("%{:02X}", byte));
1092 }
1093 }
1094 out
1095}
1096
1097fn random_bytes<const N: usize>() -> [u8; N] {
1098 use rand::RngCore;
1099 let mut buf = [0u8; N];
1100 rand::rng().fill_bytes(&mut buf);
1101 buf
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106 use super::*;
1107
1108 #[test]
1109 fn parse_cookie_handles_multi_pair_header() {
1110 let mut headers = HeaderMap::new();
1111 headers.insert(
1112 header::COOKIE,
1113 "assay_session=sess_abc; assay_csrf=csrf_xyz; other=1"
1114 .parse()
1115 .unwrap(),
1116 );
1117 assert_eq!(
1118 parse_cookie(&headers, "assay_session").as_deref(),
1119 Some("sess_abc")
1120 );
1121 assert_eq!(
1122 parse_cookie(&headers, "assay_csrf").as_deref(),
1123 Some("csrf_xyz")
1124 );
1125 assert_eq!(parse_cookie(&headers, "missing"), None);
1126 }
1127
1128 #[test]
1129 fn resume_round_trip() {
1130 let req = AuthorizeRequest {
1131 response_type: "code".into(),
1132 client_id: "c1".into(),
1133 redirect_uri: "https://app/cb".into(),
1134 scope: "openid email".into(),
1135 state: Some("s1".into()),
1136 nonce: None,
1137 code_challenge: Some("ch".into()),
1138 code_challenge_method: Some("S256".into()),
1139 prompt: None,
1140 max_age: None,
1141 };
1142 let encoded = encode_resume(&req);
1143 let decoded = decode_resume(&encoded).unwrap();
1144 assert_eq!(decoded, req);
1145 }
1146
1147 #[test]
1148 fn verify_client_secret_handles_plaintext() {
1149 assert!(verify_client_secret("secret", "secret"));
1150 assert!(!verify_client_secret("wrong", "secret"));
1151 assert!(!verify_client_secret("secret", "differentlength"));
1152 }
1153
1154 #[test]
1155 fn url_encode_handles_reserved_bytes() {
1156 assert_eq!(url_encode("a b/c"), "a%20b%2Fc");
1157 assert_eq!(url_encode("Plain-Text_1.0~"), "Plain-Text_1.0~");
1158 }
1159}