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::{self as authz, AuthorizeRequest, AuthorizeValidation};
29use super::consent::{ConsentPage, ConsentSubmission, scopes_already_granted};
30use super::introspect::{IntrospectRequest, IntrospectResponse};
31use super::revoke::RevokeRequest;
32use super::token::{self as tok, TokenErrorBody, TokenRequest, TokenResponse, errors};
33use super::types::{ConsentGrant, OidcSession};
34use super::userinfo::{self, ProviderAccessTokenClaims};
35
36const RESUME_COOKIE: &str = "assay_oidc_resume";
40
41const UPSTREAM_BINDING_COOKIE: &str = "assay_oidc_binding";
46
47pub async fn authorize_get(
54 State(ctx): State<AuthCtx>,
55 headers: HeaderMap,
56 Query(req): Query<AuthorizeRequest>,
57) -> Response {
58 let provider = match ctx.oidc_provider.as_ref() {
59 Some(p) => p,
60 None => return server_misconfigured("oidc_provider is not enabled"),
61 };
62
63 let client = match provider.clients.get(&req.client_id).await {
65 Ok(Some(c)) => c,
66 Ok(None) => {
67 return error_html(
68 StatusCode::BAD_REQUEST,
69 &format!("unknown client_id {:?}", req.client_id),
70 );
71 }
72 Err(e) => return server_error_html(&format!("client lookup failed: {e}")),
73 };
74
75 match authz::validate(&req, &client) {
77 AuthorizeValidation::Ok { scopes } => {
78 authorize_post_validate(ctx.clone(), &headers, req, client, scopes).await
79 }
80 AuthorizeValidation::Fatal { reason } => error_html(StatusCode::BAD_REQUEST, &reason),
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
131 .consents
132 .get(&session.user_id, &client.client_id)
133 .await
134 {
135 Ok(Some(grant)) => !scopes_already_granted(&scopes, &grant.scopes),
136 _ => true,
137 }
138 };
139
140 if needs_consent {
141 let resume = encode_resume(&req);
142 let page = ConsentPage {
143 client_name: &client.name,
144 issuer: &provider.issuer,
145 scopes: &scopes,
146 csrf_token: &session.csrf_token,
147 resume_token: &resume,
148 };
149 let mut response = Html(page.render_html()).into_response();
150 if let Ok(value) = format!(
153 "{}={}; Path=/; HttpOnly; SameSite=Lax",
154 RESUME_COOKIE, resume
155 )
156 .parse()
157 {
158 response.headers_mut().append(header::SET_COOKIE, value);
159 }
160 return response;
161 }
162
163 issue_authorization_code(&ctx, req, &session.user_id, scopes).await
165}
166
167async fn issue_authorization_code(
170 ctx: &AuthCtx,
171 req: AuthorizeRequest,
172 user_id: &str,
173 scopes: Vec<String>,
174) -> Response {
175 let provider = match ctx.oidc_provider.as_ref() {
176 Some(p) => p,
177 None => return server_misconfigured("oidc_provider is not enabled"),
178 };
179 let code = authz::build_code(user_id, &req, scopes);
180 if let Err(e) = provider.codes.create(&code).await {
181 return server_error_html(&format!("persist authorization code: {e}"));
182 }
183 let redirect = authz::redirect_with_code(&req.redirect_uri, &code.code, req.state.as_deref());
184 Redirect::to(&redirect).into_response()
185}
186
187fn rebuild_authorize_url(ctx: &AuthCtx, req: &AuthorizeRequest) -> String {
190 let issuer = ctx
191 .oidc_provider
192 .as_ref()
193 .map(|p| p.issuer.as_str())
194 .unwrap_or("");
195 let mut url = format!(
196 "{issuer}/authorize?response_type={}",
197 url_encode(&req.response_type)
198 );
199 url.push_str(&format!("&client_id={}", url_encode(&req.client_id)));
200 url.push_str(&format!("&redirect_uri={}", url_encode(&req.redirect_uri)));
201 url.push_str(&format!("&scope={}", url_encode(&req.scope)));
202 if let Some(s) = &req.state {
203 url.push_str(&format!("&state={}", url_encode(s)));
204 }
205 if let Some(n) = &req.nonce {
206 url.push_str(&format!("&nonce={}", url_encode(n)));
207 }
208 if let Some(c) = &req.code_challenge {
209 url.push_str(&format!("&code_challenge={}", url_encode(c)));
210 }
211 if let Some(m) = &req.code_challenge_method {
212 url.push_str(&format!("&code_challenge_method={}", url_encode(m)));
213 }
214 url
215}
216
217pub async fn consent_post(
226 State(ctx): State<AuthCtx>,
227 headers: HeaderMap,
228 Form(submission): Form<ConsentSubmission>,
229) -> Response {
230 let provider = match ctx.oidc_provider.as_ref() {
231 Some(p) => p,
232 None => return server_misconfigured("oidc_provider is not enabled"),
233 };
234
235 let resume = match parse_cookie(&headers, RESUME_COOKIE) {
238 Some(c) => c,
239 None => {
240 return error_html(
241 StatusCode::BAD_REQUEST,
242 "consent flow has no resume token (cookie missing)",
243 );
244 }
245 };
246 let req = match decode_resume(&resume) {
247 Some(r) => r,
248 None => {
249 return error_html(
250 StatusCode::BAD_REQUEST,
251 "consent resume payload is malformed",
252 );
253 }
254 };
255
256 let session = match parse_cookie(&headers, crate::session::SESSION_COOKIE) {
258 Some(sid) => ctx.sessions.get(&sid).await.ok().flatten(),
259 None => None,
260 };
261 let Some(session) = session else {
262 return error_html(StatusCode::UNAUTHORIZED, "no active session");
263 };
264 if session.csrf_token != submission.csrf_token {
265 return error_html(StatusCode::FORBIDDEN, "csrf mismatch");
266 }
267
268 let client = match provider.clients.get(&req.client_id).await {
271 Ok(Some(c)) => c,
272 _ => return error_html(StatusCode::BAD_REQUEST, "unknown client_id"),
273 };
274 let scopes: Vec<String> = req
275 .scope
276 .split_whitespace()
277 .map(|s| s.to_string())
278 .collect();
279
280 if !submission.allowed() {
281 let redirect = authz::redirect_with_error(
283 &req.redirect_uri,
284 "access_denied",
285 "user denied consent",
286 req.state.as_deref(),
287 );
288 return Redirect::to(&redirect).into_response();
289 }
290
291 let grant = ConsentGrant {
293 user_id: session.user_id.clone(),
294 client_id: client.client_id.clone(),
295 scopes: scopes.clone(),
296 granted_at: now_secs(),
297 };
298 if let Err(e) = provider.consents.upsert(&grant).await {
299 return server_error_html(&format!("persist consent: {e}"));
300 }
301
302 issue_authorization_code(&ctx, req, &session.user_id, scopes).await
303}
304
305pub async fn token_post(
312 State(ctx): State<AuthCtx>,
313 headers: HeaderMap,
314 Form(req): Form<TokenRequest>,
315) -> Response {
316 let _provider = match ctx.oidc_provider.as_ref() {
317 Some(p) => p,
318 None => return server_misconfigured("oidc_provider is not enabled"),
319 };
320
321 let client = match authenticate_client(&ctx, &headers, &req).await {
323 Ok(c) => c,
324 Err((status, body)) => return (status, Json(body)).into_response(),
325 };
326
327 match req.grant_type.as_str() {
328 "authorization_code" => grant_authorization_code(&ctx, &client, &req).await,
329 "refresh_token" => grant_refresh(&ctx, &client, &req).await,
330 other => token_err(
331 StatusCode::BAD_REQUEST,
332 errors::UNSUPPORTED_GRANT_TYPE,
333 Some(format!("grant_type {other:?} is not supported")),
334 ),
335 }
336}
337
338async fn authenticate_client(
342 ctx: &AuthCtx,
343 headers: &HeaderMap,
344 req: &TokenRequest,
345) -> Result<super::types::OidcClient, (StatusCode, TokenErrorBody)> {
346 let provider = ctx.oidc_provider.as_ref().ok_or_else(|| {
347 (
348 StatusCode::INTERNAL_SERVER_ERROR,
349 err_body(errors::SERVER_ERROR, None),
350 )
351 })?;
352
353 let basic = headers
355 .get(header::AUTHORIZATION)
356 .and_then(|v| v.to_str().ok())
357 .and_then(|s| s.strip_prefix("Basic "))
358 .or_else(|| {
359 headers
360 .get(header::AUTHORIZATION)
361 .and_then(|v| v.to_str().ok())
362 .and_then(|s| s.strip_prefix("basic "))
363 })
364 .and_then(|enc| data_encoding::BASE64.decode(enc.as_bytes()).ok())
365 .and_then(|bytes| String::from_utf8(bytes).ok())
366 .and_then(|s| {
367 let (id, secret) = s.split_once(':')?;
368 Some((id.to_string(), secret.to_string()))
369 });
370
371 let (client_id, presented_secret) = match (basic, &req.client_id) {
372 (Some((id, secret)), _) => (id, Some(secret)),
373 (None, Some(id)) => (id.clone(), req.client_secret.clone()),
374 (None, None) => {
375 return Err((
376 StatusCode::UNAUTHORIZED,
377 err_body(errors::INVALID_CLIENT, Some("client_id missing".into())),
378 ));
379 }
380 };
381
382 let client = match provider.clients.get(&client_id).await {
383 Ok(Some(c)) => c,
384 _ => {
385 return Err((
386 StatusCode::UNAUTHORIZED,
387 err_body(errors::INVALID_CLIENT, Some("unknown client".into())),
388 ));
389 }
390 };
391
392 match client.token_endpoint_auth_method {
393 super::types::TokenAuthMethod::None => {
394 Ok(client)
396 }
397 super::types::TokenAuthMethod::ClientSecretBasic
398 | super::types::TokenAuthMethod::ClientSecretPost => {
399 let presented = presented_secret
400 .as_deref()
401 .map(|s| s.to_string())
402 .unwrap_or_default();
403 let stored = client.client_secret_hash.as_deref().unwrap_or("");
404 if !verify_client_secret(&presented, stored) {
405 return Err((
406 StatusCode::UNAUTHORIZED,
407 err_body(errors::INVALID_CLIENT, Some("bad secret".into())),
408 ));
409 }
410 Ok(client)
411 }
412 super::types::TokenAuthMethod::PrivateKeyJwt => {
413 Err((
415 StatusCode::BAD_REQUEST,
416 err_body(
417 errors::INVALID_CLIENT,
418 Some("private_key_jwt not yet supported".into()),
419 ),
420 ))
421 }
422 }
423}
424
425fn verify_client_secret(presented: &str, stored: &str) -> bool {
430 if stored.starts_with("$argon2") {
431 let hasher = crate::password::PasswordHasher::default();
432 return hasher.verify(presented, stored).unwrap_or(false);
433 }
434 let a = presented.as_bytes();
436 let b = stored.as_bytes();
437 if a.len() != b.len() {
438 return false;
439 }
440 let mut diff = 0u8;
441 for (x, y) in a.iter().zip(b.iter()) {
442 diff |= x ^ y;
443 }
444 diff == 0
445}
446
447async fn grant_authorization_code(
451 ctx: &AuthCtx,
452 client: &super::types::OidcClient,
453 req: &TokenRequest,
454) -> Response {
455 let provider = match ctx.oidc_provider.as_ref() {
456 Some(p) => p,
457 None => {
458 return token_err(
459 StatusCode::INTERNAL_SERVER_ERROR,
460 errors::SERVER_ERROR,
461 None,
462 );
463 }
464 };
465 let Some(code_str) = req.code.as_deref() else {
466 return token_err(
467 StatusCode::BAD_REQUEST,
468 errors::INVALID_REQUEST,
469 Some("code is required".into()),
470 );
471 };
472 let consumed = match provider.codes.consume(code_str).await {
473 Ok(Some(c)) => c,
474 Ok(None) => {
475 return token_err(
476 StatusCode::BAD_REQUEST,
477 errors::INVALID_GRANT,
478 Some("code is unknown or already used".into()),
479 );
480 }
481 Err(e) => {
482 return token_err(
483 StatusCode::INTERNAL_SERVER_ERROR,
484 errors::SERVER_ERROR,
485 Some(format!("consume code: {e}")),
486 );
487 }
488 };
489 if consumed.expires_at <= now_secs() {
490 return token_err(
491 StatusCode::BAD_REQUEST,
492 errors::INVALID_GRANT,
493 Some("code expired".into()),
494 );
495 }
496 if consumed.client_id != client.client_id {
497 return token_err(
498 StatusCode::BAD_REQUEST,
499 errors::INVALID_GRANT,
500 Some("code does not belong to this client".into()),
501 );
502 }
503 if let Some(redirect) = &req.redirect_uri
504 && redirect != &consumed.redirect_uri
505 {
506 return token_err(
507 StatusCode::BAD_REQUEST,
508 errors::INVALID_GRANT,
509 Some("redirect_uri mismatch".into()),
510 );
511 }
512 if !consumed.code_challenge.is_empty() {
514 let verifier = req.code_verifier.as_deref().unwrap_or("");
515 if !tok::verify_pkce_s256(verifier, &consumed.code_challenge) {
516 return token_err(
517 StatusCode::BAD_REQUEST,
518 errors::INVALID_GRANT,
519 Some("PKCE verifier mismatch".into()),
520 );
521 }
522 }
523
524 issue_token_pair(
525 ctx,
526 client,
527 &consumed.user_id,
528 &consumed.scopes,
529 consumed.nonce.as_deref(),
530 )
531 .await
532}
533
534async fn grant_refresh(
538 ctx: &AuthCtx,
539 client: &super::types::OidcClient,
540 req: &TokenRequest,
541) -> Response {
542 let provider = match ctx.oidc_provider.as_ref() {
543 Some(p) => p,
544 None => {
545 return token_err(
546 StatusCode::INTERNAL_SERVER_ERROR,
547 errors::SERVER_ERROR,
548 None,
549 );
550 }
551 };
552 let Some(presented) = req.refresh_token.as_deref() else {
553 return token_err(
554 StatusCode::BAD_REQUEST,
555 errors::INVALID_REQUEST,
556 Some("refresh_token is required".into()),
557 );
558 };
559 let hash = tok::hash_refresh_token(presented);
560 let row = match provider.refresh.get(&hash).await {
561 Ok(Some(r)) => r,
562 Ok(None) => {
563 return token_err(
564 StatusCode::BAD_REQUEST,
565 errors::INVALID_GRANT,
566 Some("refresh_token unknown".into()),
567 );
568 }
569 Err(e) => {
570 return token_err(
571 StatusCode::INTERNAL_SERVER_ERROR,
572 errors::SERVER_ERROR,
573 Some(format!("refresh lookup: {e}")),
574 );
575 }
576 };
577 if row.revoked {
578 let _ = provider.refresh.revoke_for_user(&row.user_id).await;
580 return token_err(
581 StatusCode::BAD_REQUEST,
582 errors::INVALID_GRANT,
583 Some("refresh_token revoked (replay detected)".into()),
584 );
585 }
586 if row.expires_at <= now_secs() {
587 return token_err(
588 StatusCode::BAD_REQUEST,
589 errors::INVALID_GRANT,
590 Some("refresh_token expired".into()),
591 );
592 }
593 if row.client_id != client.client_id {
594 return token_err(
595 StatusCode::BAD_REQUEST,
596 errors::INVALID_GRANT,
597 Some("refresh_token client mismatch".into()),
598 );
599 }
600 if let Err(e) = provider.refresh.revoke(&hash).await {
601 return token_err(
602 StatusCode::INTERNAL_SERVER_ERROR,
603 errors::SERVER_ERROR,
604 Some(format!("revoke old refresh: {e}")),
605 );
606 }
607 issue_token_pair(ctx, client, &row.user_id, &row.scopes, None).await
608}
609
610async fn issue_token_pair(
615 ctx: &AuthCtx,
616 client: &super::types::OidcClient,
617 user_id: &str,
618 scopes: &[String],
619 nonce: Option<&str>,
620) -> Response {
621 let provider = match ctx.oidc_provider.as_ref() {
622 Some(p) => p,
623 None => {
624 return token_err(
625 StatusCode::INTERNAL_SERVER_ERROR,
626 errors::SERVER_ERROR,
627 None,
628 );
629 }
630 };
631 let user = match ctx.users.get_user_by_id(user_id).await {
632 Ok(Some(u)) => Some(u),
633 _ => None,
634 };
635 let email = user.as_ref().and_then(|u| u.email.clone());
636 let email_verified = user.as_ref().map(|u| u.email_verified).unwrap_or(false);
637 let display_name = user.as_ref().and_then(|u| u.display_name.clone());
638
639 let sid = tok::mint_sid();
640
641 let id_claims = tok::build_id_token_claims(
642 &provider.issuer,
643 user_id,
644 &client.client_id,
645 &sid,
646 scopes,
647 nonce,
648 email.as_deref(),
649 email_verified,
650 display_name.as_deref(),
651 );
652 let access_claims =
653 tok::build_access_token_claims(&provider.issuer, user_id, &client.client_id, &sid, scopes);
654
655 let jwt = match ctx.jwt.as_ref() {
656 Some(j) => j,
657 None => {
658 return token_err(
659 StatusCode::INTERNAL_SERVER_ERROR,
660 errors::SERVER_ERROR,
661 Some("jwt not configured".into()),
662 );
663 }
664 };
665 let id_token = match jwt.issue(&id_claims) {
666 Ok(t) => t,
667 Err(e) => {
668 return token_err(
669 StatusCode::INTERNAL_SERVER_ERROR,
670 errors::SERVER_ERROR,
671 Some(format!("sign id_token: {e}")),
672 );
673 }
674 };
675 let access_token = match jwt.issue(&access_claims) {
676 Ok(t) => t,
677 Err(e) => {
678 return token_err(
679 StatusCode::INTERNAL_SERVER_ERROR,
680 errors::SERVER_ERROR,
681 Some(format!("sign access_token: {e}")),
682 );
683 }
684 };
685
686 let issue_refresh =
689 client.allows_grant("refresh_token") || scopes.iter().any(|s| s == "offline_access");
690 let refresh_token = if issue_refresh {
691 let plaintext = tok::mint_refresh_token();
692 let row = tok::build_refresh_row(user_id, &client.client_id, scopes, &plaintext);
693 if let Err(e) = provider.refresh.create(&row).await {
694 return token_err(
695 StatusCode::INTERNAL_SERVER_ERROR,
696 errors::SERVER_ERROR,
697 Some(format!("persist refresh: {e}")),
698 );
699 }
700 Some(plaintext)
701 } else {
702 None
703 };
704
705 let oidc_session = OidcSession {
707 sid: sid.clone(),
708 user_id: user_id.to_string(),
709 client_id: client.client_id.clone(),
710 assay_session_id: None,
711 issued_at: now_secs(),
712 backchannel_logout_uri: client.backchannel_logout_uri.clone(),
713 };
714 if let Err(e) = provider.sessions.create(&oidc_session).await {
715 tracing::warn!(?e, "failed to record SSO session — continuing");
716 }
717
718 let response = TokenResponse {
719 access_token,
720 token_type: "Bearer",
721 expires_in: tok::ACCESS_TOKEN_LIFETIME_SECS as i64,
722 id_token,
723 refresh_token,
724 scope: scopes.join(" "),
725 };
726 (StatusCode::OK, Json(response)).into_response()
727}
728
729pub async fn userinfo_get(State(ctx): State<AuthCtx>, headers: HeaderMap) -> Response {
735 let bearer = headers
736 .get(header::AUTHORIZATION)
737 .and_then(|v| v.to_str().ok())
738 .and_then(userinfo::parse_bearer);
739 let Some(token) = bearer else {
740 return (
741 StatusCode::UNAUTHORIZED,
742 Json(json!({"error": "invalid_token"})),
743 )
744 .into_response();
745 };
746 let jwt = match ctx.jwt.as_ref() {
747 Some(j) => j,
748 None => {
749 return (
750 StatusCode::INTERNAL_SERVER_ERROR,
751 Json(json!({"error": "server_error"})),
752 )
753 .into_response();
754 }
755 };
756 let data = match jwt.verify_provider_token::<ProviderAccessTokenClaims>(token) {
757 Ok(d) if d.claims.is_provider_access_token() => d,
758 Err(_) => {
759 return (
760 StatusCode::UNAUTHORIZED,
761 Json(json!({"error": "invalid_token"})),
762 )
763 .into_response();
764 }
765 Ok(_) => {
766 return (
767 StatusCode::UNAUTHORIZED,
768 Json(json!({"error": "invalid_token"})),
769 )
770 .into_response();
771 }
772 };
773 let user = match ctx.users.get_user_by_id(&data.claims.claims.sub).await {
774 Ok(Some(u)) => u,
775 _ => {
776 return (
777 StatusCode::UNAUTHORIZED,
778 Json(json!({"error": "invalid_token"})),
779 )
780 .into_response();
781 }
782 };
783 let claims = userinfo::build_userinfo(&user, &data.claims.claims.scopes());
784 (StatusCode::OK, Json(claims)).into_response()
785}
786
787pub async fn revoke_post(State(ctx): State<AuthCtx>, Form(req): Form<RevokeRequest>) -> Response {
793 if let Some(provider) = ctx.oidc_provider.as_ref() {
794 let hash = tok::hash_refresh_token(&req.token);
796 let _ = provider.refresh.revoke(&hash).await;
797 }
798 StatusCode::OK.into_response()
799}
800
801pub async fn introspect_post(
809 State(ctx): State<AuthCtx>,
810 headers: HeaderMap,
811 Form(body): Form<IntrospectRequest>,
812) -> Response {
813 let synth = TokenRequest {
816 grant_type: String::new(),
817 ..Default::default()
818 };
819 if authenticate_client(&ctx, &headers, &synth).await.is_err() {
820 return (
821 StatusCode::UNAUTHORIZED,
822 Json(IntrospectResponse::inactive()),
823 )
824 .into_response();
825 }
826
827 let jwt = match ctx.jwt.as_ref() {
828 Some(j) => j,
829 None => return (StatusCode::OK, Json(IntrospectResponse::inactive())).into_response(),
830 };
831
832 if let Ok(data) = jwt.verify_provider_token::<ProviderAccessTokenClaims>(&body.token)
834 && data.claims.is_provider_access_token()
835 {
836 let claims = data.claims.claims;
837 let resp = IntrospectResponse {
838 active: true,
839 client_id: Some(claims.client_id.clone()),
840 username: Some(claims.sub.clone()),
841 scope: Some(claims.scope.clone()),
842 exp: Some(claims.exp),
843 sub: Some(claims.sub.clone()),
844 aud: Some(claims.aud.clone()),
845 iat: Some(claims.iat),
846 token_type: Some("Bearer".into()),
847 };
848 return (StatusCode::OK, Json(resp)).into_response();
849 }
850
851 if let Some(provider) = ctx.oidc_provider.as_ref() {
853 let hash = tok::hash_refresh_token(&body.token);
854 if let Ok(Some(row)) = provider.refresh.get(&hash).await
855 && !row.revoked
856 && row.expires_at > now_secs()
857 {
858 let resp = IntrospectResponse {
859 active: true,
860 client_id: Some(row.client_id.clone()),
861 username: Some(row.user_id.clone()),
862 scope: Some(row.scopes.join(" ")),
863 exp: Some(row.expires_at as i64),
864 sub: Some(row.user_id),
865 aud: Some(row.client_id),
866 iat: Some(row.issued_at as i64),
867 token_type: Some("Bearer".into()),
868 };
869 return (StatusCode::OK, Json(resp)).into_response();
870 }
871 }
872
873 (StatusCode::OK, Json(IntrospectResponse::inactive())).into_response()
874}
875
876#[derive(Deserialize)]
882pub struct LogoutQuery {
883 pub id_token_hint: Option<String>,
884 pub post_logout_redirect_uri: Option<String>,
885 pub state: Option<String>,
886}
887
888pub async fn logout_get(
890 State(ctx): State<AuthCtx>,
891 headers: HeaderMap,
892 Query(q): Query<LogoutQuery>,
893) -> Response {
894 if let Some(sid) = parse_cookie(&headers, crate::session::SESSION_COOKIE) {
895 let _ = ctx.sessions.delete(&sid).await;
896 if let Some(provider) = ctx.oidc_provider.as_ref() {
899 if let Ok(rows) = provider.sessions.list_by_assay_session(&sid).await {
900 for row in rows {
901 if let Some(uri) = row.backchannel_logout_uri {
902 tokio::spawn(async move {
905 let client = reqwest::Client::new();
906 let _ = client
907 .post(&uri)
908 .form(&[("logout_token", "stub")])
909 .timeout(std::time::Duration::from_secs(5))
910 .send()
911 .await;
912 });
913 }
914 }
915 }
916 let _ = provider.sessions.delete_by_assay_session(&sid).await;
917 }
918 }
919 let _ = q.id_token_hint;
920 let _ = q.state;
921 let target = q
922 .post_logout_redirect_uri
923 .unwrap_or_else(|| "/".to_string());
924 let mut response = Redirect::to(&target).into_response();
925 if let Ok(value) = format!(
927 "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
928 crate::session::SESSION_COOKIE
929 )
930 .parse()
931 {
932 response.headers_mut().append(header::SET_COOKIE, value);
933 }
934 response
935}
936
937#[derive(Deserialize)]
943pub struct UpstreamStartQuery {
944 pub return_to: Option<String>,
945}
946
947pub async fn upstream_start(
949 State(ctx): State<AuthCtx>,
950 Path(slug): Path<String>,
951 Query(q): Query<UpstreamStartQuery>,
952) -> Response {
953 let provider = match ctx.oidc_provider.as_ref() {
954 Some(p) => p,
955 None => return server_misconfigured("oidc_provider is not enabled"),
956 };
957 let registry = match ctx.oidc.as_ref() {
958 Some(r) => r,
959 None => return server_misconfigured("oidc client registry is not enabled"),
960 };
961 let started = match super::federation::start_upstream_login(
962 registry,
963 &provider.upstream_states,
964 &slug,
965 validate_return_to(q.return_to, &provider.public_url),
966 )
967 .await
968 {
969 Ok(s) => s,
970 Err(e) => {
971 return error_html(StatusCode::BAD_REQUEST, &format!("upstream start: {e}"));
972 }
973 };
974 let mut response = Redirect::to(&started.redirect_url).into_response();
975 if let Ok(value) = build_binding_cookie(&started.binding_token, &provider.public_url).parse() {
976 response.headers_mut().append(header::SET_COOKIE, value);
977 }
978 response
979}
980
981#[derive(Deserialize)]
984pub struct UpstreamCallbackQuery {
985 pub code: String,
986 pub state: String,
987 pub iss: Option<String>,
988}
989
990pub async fn upstream_callback(
992 State(ctx): State<AuthCtx>,
993 headers: HeaderMap,
994 Path(_slug): Path<String>,
995 Query(q): Query<UpstreamCallbackQuery>,
996) -> Response {
997 let provider = match ctx.oidc_provider.as_ref() {
998 Some(p) => p,
999 None => return server_misconfigured("oidc_provider is not enabled"),
1000 };
1001 let registry = match ctx.oidc.as_ref() {
1002 Some(r) => r,
1003 None => return server_misconfigured("oidc client registry is not enabled"),
1004 };
1005 let binding_token = parse_cookie(&headers, UPSTREAM_BINDING_COOKIE);
1006 let info = match super::federation::complete_upstream_login(
1007 registry,
1008 &provider.upstream_states,
1009 &q.code,
1010 &q.state,
1011 binding_token.as_deref(),
1012 q.iss.as_deref(),
1013 )
1014 .await
1015 {
1016 Ok(i) => i,
1017 Err(e) => {
1018 let mut response =
1019 error_html(StatusCode::BAD_REQUEST, &format!("upstream complete: {e}"));
1020 append_clear_binding_cookie(&mut response, &provider.public_url);
1021 return response;
1022 }
1023 };
1024
1025 let user = match ctx
1034 .users
1035 .get_user_by_upstream(&info.provider_slug, &info.subject)
1036 .await
1037 {
1038 Ok(Some(u)) => u,
1039 Ok(None) => {
1040 let existing = if provider.auto_provision {
1041 None
1042 } else {
1043 let email = match info.email.as_deref() {
1050 Some(e) if !e.is_empty() => e,
1051 _ => {
1052 let mut response = error_html(
1053 StatusCode::FORBIDDEN,
1054 "upstream did not return an email claim; \
1055 cannot match against the access list.",
1056 );
1057 append_clear_binding_cookie(&mut response, &provider.public_url);
1058 return response;
1059 }
1060 };
1061 if !info.email_verified {
1062 let mut response = error_html(
1063 StatusCode::FORBIDDEN,
1064 "upstream returned an unverified email; \
1065 cannot match against the access list. Verify \
1066 the address with the upstream provider first.",
1067 );
1068 append_clear_binding_cookie(&mut response, &provider.public_url);
1069 return response;
1070 }
1071 match ctx.users.get_user_by_email(email).await {
1072 Ok(Some(u)) => Some(u),
1073 Ok(None) => {
1074 let mut response = error_html(
1075 StatusCode::FORBIDDEN,
1076 &format!(
1077 "You signed in as {email}, but that account is \
1078 not yet authorised for this app. If you believe \
1079 this is a mistake, ask an administrator to invite \
1080 you."
1081 ),
1082 );
1083 append_clear_binding_cookie(&mut response, &provider.public_url);
1084 return response;
1085 }
1086 Err(e) => {
1087 let mut response = server_error_html(&format!("user lookup by email: {e}"));
1088 append_clear_binding_cookie(&mut response, &provider.public_url);
1089 return response;
1090 }
1091 }
1092 };
1093 let user = if let Some(u) = existing {
1094 u
1096 } else {
1097 let id = format!(
1098 "usr_{}",
1099 data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<16>())
1100 );
1101 let user = crate::store::User {
1102 id: id.clone(),
1103 email: info.email.clone(),
1104 email_verified: info.email_verified,
1105 display_name: info.display_name.clone(),
1106 created_at: now_secs(),
1107 };
1108 if let Err(e) = ctx.users.create_user(&user).await {
1109 let mut response = server_error_html(&format!("create user: {e}"));
1110 append_clear_binding_cookie(&mut response, &provider.public_url);
1111 return response;
1112 }
1113 user
1114 };
1115 if let Err(e) = ctx
1116 .users
1117 .link_upstream(&user.id, &info.provider_slug, &info.subject)
1118 .await
1119 {
1120 let mut response = server_error_html(&format!("link upstream: {e}"));
1121 append_clear_binding_cookie(&mut response, &provider.public_url);
1122 return response;
1123 }
1124 user
1125 }
1126 Err(e) => {
1127 let mut response = server_error_html(&format!("upstream user lookup: {e}"));
1128 append_clear_binding_cookie(&mut response, &provider.public_url);
1129 return response;
1130 }
1131 };
1132
1133 let mgr = crate::session::SessionManager::with_default_duration(ctx.sessions.clone());
1135 let session = match mgr.create(&user.id).await {
1136 Ok(s) => s,
1137 Err(e) => {
1138 let mut response = server_error_html(&format!("create session: {e}"));
1139 append_clear_binding_cookie(&mut response, &provider.public_url);
1140 return response;
1141 }
1142 };
1143 let mut response = Redirect::to(info.return_to.as_deref().unwrap_or("/")).into_response();
1144 let cookie = crate::session::cookie_for(&session, &provider.public_url);
1145 if let Ok(value) = cookie.to_string().parse() {
1146 response.headers_mut().append(header::SET_COOKIE, value);
1147 }
1148 append_clear_binding_cookie(&mut response, &provider.public_url);
1149 response
1150}
1151
1152fn binding_cookie_path(public_url: &url::Url) -> String {
1160 let base = public_url.path().trim_end_matches('/');
1161 format!("{base}/oidc/upstream/")
1162}
1163
1164fn build_binding_cookie(raw: &str, public_url: &url::Url) -> String {
1165 let secure = !is_plain_http(public_url);
1166 let secure_attr = if secure { "; Secure" } else { "" };
1167 let path = binding_cookie_path(public_url);
1168 format!(
1169 "{UPSTREAM_BINDING_COOKIE}={raw}; Path={path}; HttpOnly; SameSite=Lax; \
1170 Max-Age=300{secure_attr}"
1171 )
1172}
1173
1174fn append_clear_binding_cookie(response: &mut Response, public_url: &url::Url) {
1178 let path = binding_cookie_path(public_url);
1179 let cleared =
1180 format!("{UPSTREAM_BINDING_COOKIE}=; Path={path}; Max-Age=0; HttpOnly; SameSite=Lax");
1181 if let Ok(value) = cleared.parse() {
1182 response.headers_mut().append(header::SET_COOKIE, value);
1183 }
1184}
1185
1186fn is_plain_http(url: &url::Url) -> bool {
1187 url.scheme() != "https"
1188}
1189
1190fn validate_return_to(raw: Option<String>, public_url: &url::Url) -> Option<String> {
1202 let s = raw?;
1203 if s.is_empty() {
1204 return None;
1205 }
1206 if s.starts_with("//") {
1209 return None;
1210 }
1211 if s.starts_with('/') {
1213 return Some(s);
1214 }
1215 match url::Url::parse(&s) {
1217 Ok(u) if u.origin() == public_url.origin() => Some(s),
1218 _ => None,
1219 }
1220}
1221
1222fn encode_resume(req: &AuthorizeRequest) -> String {
1230 let json = serde_json::to_vec(req).unwrap_or_default();
1231 data_encoding::BASE64URL_NOPAD.encode(&json)
1232}
1233
1234fn decode_resume(s: &str) -> Option<AuthorizeRequest> {
1235 let bytes = data_encoding::BASE64URL_NOPAD.decode(s.as_bytes()).ok()?;
1236 serde_json::from_slice(&bytes).ok()
1237}
1238
1239pub(crate) fn parse_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
1241 let raw = headers.get(header::COOKIE)?.to_str().ok()?;
1242 for kv in raw.split(';') {
1243 let kv = kv.trim();
1244 if let Some((k, v)) = kv.split_once('=')
1245 && k == name
1246 {
1247 return Some(v.to_string());
1248 }
1249 }
1250 None
1251}
1252
1253fn token_err(status: StatusCode, code: &str, desc: Option<String>) -> Response {
1255 (status, Json(err_body(code, desc))).into_response()
1256}
1257
1258fn err_body(code: &str, desc: Option<String>) -> TokenErrorBody {
1259 TokenErrorBody {
1260 error: code.to_string(),
1261 error_description: desc,
1262 }
1263}
1264
1265fn error_html(status: StatusCode, message: &str) -> Response {
1268 let title = match status {
1269 StatusCode::FORBIDDEN => "Access denied",
1270 StatusCode::UNAUTHORIZED => "Sign-in required",
1271 StatusCode::BAD_REQUEST => "Bad request",
1272 StatusCode::INTERNAL_SERVER_ERROR => "Server error",
1273 _ => "Error",
1274 };
1275 let body = format!(
1276 r#"<!DOCTYPE html>
1277<html lang="en">
1278<head>
1279<meta charset="UTF-8">
1280<meta name="viewport" content="width=device-width, initial-scale=1.0">
1281<title>{title}</title>
1282<style>
1283:root {{
1284 color-scheme: light dark;
1285 --bg: #0d1117; --card: #161b22; --text: #e6edf3; --muted: #8b949e;
1286 --accent: #e6662a; --border: #30363d;
1287}}
1288@media (prefers-color-scheme: light) {{
1289 :root {{
1290 --bg: #f6f8fa; --card: #ffffff; --text: #1f2328; --muted: #59636e;
1291 --accent: #cf5d27; --border: #d0d7de;
1292 }}
1293}}
1294html, body {{ height: 100%; }}
1295body {{
1296 margin: 0;
1297 background: var(--bg);
1298 color: var(--text);
1299 font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
1300 display: flex; align-items: center; justify-content: center;
1301 padding: 1.5rem; box-sizing: border-box;
1302}}
1303.error-card {{
1304 background: var(--card);
1305 border: 1px solid var(--border);
1306 border-radius: 12px;
1307 padding: 2.5rem 2.25rem;
1308 width: 100%; max-width: 420px;
1309 box-sizing: border-box;
1310 box-shadow: 0 8px 24px rgba(0,0,0,.24);
1311 text-align: center;
1312}}
1313h1 {{ margin: 0 0 1rem; font-size: 1.5rem; font-weight: 600; }}
1314p {{ margin: 0 0 1.5rem; color: var(--muted); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }}
1315.actions {{ display: flex; gap: .5rem; justify-content: center; flex-wrap: wrap; }}
1316.button {{
1317 display: inline-block;
1318 padding: .65rem 1.15rem;
1319 border: 1px solid var(--border); border-radius: 8px;
1320 color: var(--text); text-decoration: none;
1321 font-weight: 500; font-size: .95rem;
1322 transition: border-color 120ms, background-color 120ms;
1323}}
1324.button:hover {{ border-color: var(--accent); background: rgba(230,102,42,.06); }}
1325</style>
1326</head>
1327<body>
1328<main class="error-card">
1329<h1>{title}</h1>
1330<p>{message}</p>
1331<div class="actions">
1332 <a class="button" href="/auth/login">Try a different account</a>
1333</div>
1334</main>
1335</body>
1336</html>"#,
1337 title = html_escape_simple(title),
1338 message = html_escape_simple(message),
1339 );
1340 (status, Html(body)).into_response()
1341}
1342
1343fn html_escape_simple(s: &str) -> String {
1347 let mut out = String::with_capacity(s.len());
1348 for ch in s.chars() {
1349 match ch {
1350 '<' => out.push_str("<"),
1351 '>' => out.push_str(">"),
1352 '&' => out.push_str("&"),
1353 '"' => out.push_str("""),
1354 '\'' => out.push_str("'"),
1355 c => out.push(c),
1356 }
1357 }
1358 out
1359}
1360
1361fn server_error_html(message: &str) -> Response {
1362 error_html(StatusCode::INTERNAL_SERVER_ERROR, message)
1363}
1364
1365fn server_misconfigured(reason: &str) -> Response {
1366 error_html(StatusCode::INTERNAL_SERVER_ERROR, reason)
1367}
1368
1369fn now_secs() -> f64 {
1370 SystemTime::now()
1371 .duration_since(UNIX_EPOCH)
1372 .unwrap_or_default()
1373 .as_secs_f64()
1374}
1375
1376fn url_encode(s: &str) -> String {
1381 let mut out = String::with_capacity(s.len());
1382 for byte in s.bytes() {
1383 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
1384 out.push(byte as char);
1385 } else {
1386 out.push_str(&format!("%{:02X}", byte));
1387 }
1388 }
1389 out
1390}
1391
1392fn random_bytes<const N: usize>() -> [u8; N] {
1393 use rand::RngCore;
1394 let mut buf = [0u8; N];
1395 rand::rng().fill_bytes(&mut buf);
1396 buf
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401 use super::*;
1402
1403 #[test]
1404 fn parse_cookie_handles_multi_pair_header() {
1405 let mut headers = HeaderMap::new();
1406 headers.insert(
1407 header::COOKIE,
1408 "assay_session=sess_abc; assay_csrf=csrf_xyz; other=1"
1409 .parse()
1410 .unwrap(),
1411 );
1412 assert_eq!(
1413 parse_cookie(&headers, "assay_session").as_deref(),
1414 Some("sess_abc")
1415 );
1416 assert_eq!(
1417 parse_cookie(&headers, "assay_csrf").as_deref(),
1418 Some("csrf_xyz")
1419 );
1420 assert_eq!(parse_cookie(&headers, "missing"), None);
1421 }
1422
1423 #[test]
1424 fn resume_round_trip() {
1425 let req = AuthorizeRequest {
1426 response_type: "code".into(),
1427 client_id: "c1".into(),
1428 redirect_uri: "https://app/cb".into(),
1429 scope: "openid email".into(),
1430 state: Some("s1".into()),
1431 nonce: None,
1432 code_challenge: Some("ch".into()),
1433 code_challenge_method: Some("S256".into()),
1434 prompt: None,
1435 max_age: None,
1436 };
1437 let encoded = encode_resume(&req);
1438 let decoded = decode_resume(&encoded).unwrap();
1439 assert_eq!(decoded, req);
1440 }
1441
1442 #[test]
1443 fn verify_client_secret_handles_plaintext() {
1444 assert!(verify_client_secret("secret", "secret"));
1445 assert!(!verify_client_secret("wrong", "secret"));
1446 assert!(!verify_client_secret("secret", "differentlength"));
1447 }
1448
1449 #[test]
1450 fn url_encode_handles_reserved_bytes() {
1451 assert_eq!(url_encode("a b/c"), "a%20b%2Fc");
1452 assert_eq!(url_encode("Plain-Text_1.0~"), "Plain-Text_1.0~");
1453 }
1454
1455 fn return_to_issuer() -> url::Url {
1456 url::Url::parse("https://app.example.com/auth").unwrap()
1457 }
1458
1459 #[test]
1460 fn validate_return_to_accepts_relative_path() {
1461 let u = return_to_issuer();
1462 assert_eq!(validate_return_to(Some("/".into()), &u), Some("/".into()));
1463 assert_eq!(
1464 validate_return_to(Some("/dashboard".into()), &u),
1465 Some("/dashboard".into())
1466 );
1467 assert_eq!(
1468 validate_return_to(Some("/a?b=c#d".into()), &u),
1469 Some("/a?b=c#d".into())
1470 );
1471 }
1472
1473 #[test]
1474 fn validate_return_to_accepts_same_origin_absolute() {
1475 let u = return_to_issuer();
1476 let here = "https://app.example.com/some/path".to_string();
1477 assert_eq!(validate_return_to(Some(here.clone()), &u), Some(here));
1478 }
1479
1480 #[test]
1481 fn validate_return_to_rejects_cross_origin() {
1482 let u = return_to_issuer();
1483 assert_eq!(
1484 validate_return_to(Some("https://evil.com".into()), &u),
1485 None
1486 );
1487 assert_eq!(
1488 validate_return_to(Some("https://evil.com/path".into()), &u),
1489 None
1490 );
1491 assert_eq!(
1493 validate_return_to(Some("https://other.example.com/x".into()), &u),
1494 None
1495 );
1496 }
1497
1498 #[test]
1499 fn validate_return_to_rejects_protocol_relative() {
1500 let u = return_to_issuer();
1501 assert_eq!(validate_return_to(Some("//evil.com/x".into()), &u), None);
1502 assert_eq!(validate_return_to(Some("//evil.com".into()), &u), None);
1503 }
1504
1505 #[test]
1506 fn validate_return_to_rejects_javascript_and_data_schemes() {
1507 let u = return_to_issuer();
1508 assert_eq!(
1509 validate_return_to(Some("javascript:alert(1)".into()), &u),
1510 None
1511 );
1512 assert_eq!(
1513 validate_return_to(Some("data:text/html,<script>".into()), &u),
1514 None
1515 );
1516 }
1517
1518 #[test]
1519 fn validate_return_to_passes_through_none_and_empty() {
1520 let u = return_to_issuer();
1521 assert_eq!(validate_return_to(None, &u), None);
1522 assert_eq!(validate_return_to(Some(String::new()), &u), None);
1523 }
1524}