1use axum::Form;
2use axum::extract::Extension;
3use axum::http::header::COOKIE;
4use axum::http::{HeaderMap, StatusCode};
5use axum::response::{IntoResponse, Response};
6use serde::Deserialize;
7use url::Url;
8
9#[cfg(test)]
10use allowthem_core::applications::CreateApplicationParams;
11use allowthem_core::applications::{Application, BrandingConfig, validate_redirect_uri};
12use allowthem_core::authorization::{
13 generate_authorization_code, hash_authorization_code, validate_scopes,
14};
15use allowthem_core::types::{ClientId, UserId};
16use allowthem_core::{AllowThem, AuthError};
17
18enum OAuthErrorCode {
23 InvalidRequest,
24 AccessDenied,
25 UnsupportedResponseType,
26 InvalidScope,
27 ServerError,
28}
29
30impl OAuthErrorCode {
31 fn as_str(&self) -> &'static str {
32 match self {
33 Self::InvalidRequest => "invalid_request",
34 Self::AccessDenied => "access_denied",
35 Self::UnsupportedResponseType => "unsupported_response_type",
36 Self::InvalidScope => "invalid_scope",
37 Self::ServerError => "server_error",
38 }
39 }
40}
41
42pub enum AuthorizeOutcome {
49 Redirect(Response),
51 ConsentNeeded(Box<ConsentNeededData>),
53}
54
55pub struct ConsentNeededData {
56 pub context: ConsentContext,
57 pub params: ValidatedAuthorize,
58 pub user_email: String,
59}
60
61#[derive(Deserialize)]
66pub struct AuthorizeParams {
67 pub client_id: Option<ClientId>,
68 pub redirect_uri: Option<String>,
69 pub response_type: Option<String>,
70 pub scope: Option<String>,
71 pub state: Option<String>,
72 pub code_challenge: Option<String>,
73 pub code_challenge_method: Option<String>,
74 pub nonce: Option<String>,
75}
76
77#[derive(Deserialize)]
79pub struct ConsentSubmission {
80 client_id: Option<ClientId>,
81 redirect_uri: Option<String>,
82 response_type: Option<String>,
83 scope: Option<String>,
84 state: Option<String>,
85 code_challenge: Option<String>,
86 code_challenge_method: Option<String>,
87 nonce: Option<String>,
88 consent: String,
89 #[allow(dead_code)]
91 csrf_token: Option<String>,
92}
93
94pub struct ConsentContext {
96 pub branding: BrandingConfig,
97 pub scopes: Vec<String>,
98}
99
100pub struct ValidatedAuthorize {
102 pub application: Application,
103 pub redirect_uri: String,
104 pub scopes: Vec<String>,
105 pub state: String,
106 pub code_challenge: String,
107 pub code_challenge_method: String,
108 pub nonce: Option<String>,
109}
110
111fn success_redirect(redirect_uri: &str, code: &str, state: &str, status: StatusCode) -> Response {
117 let mut url = Url::parse(redirect_uri).expect("redirect_uri was pre-validated");
118 url.query_pairs_mut()
119 .append_pair("code", code)
120 .append_pair("state", state);
121 (status, [("location", url.as_str().to_string())]).into_response()
122}
123
124fn error_redirect(
126 redirect_uri: &str,
127 error: OAuthErrorCode,
128 description: &str,
129 state: &str,
130 status: StatusCode,
131) -> Response {
132 let mut url = Url::parse(redirect_uri).expect("redirect_uri was pre-validated");
133 url.query_pairs_mut()
134 .append_pair("error", error.as_str())
135 .append_pair("error_description", description)
136 .append_pair("state", state);
137 (status, [("location", url.as_str().to_string())]).into_response()
138}
139
140fn display_error(status: StatusCode, message: &str) -> Response {
145 let html = crate::browser_error::render_error_page("Authorization error", message);
146 (status, axum::response::Html(html)).into_response()
147}
148
149pub async fn resolve_user(
157 ath: &AllowThem,
158 headers: &HeaderMap,
159) -> Result<Option<allowthem_core::User>, AuthError> {
160 let cookie_str = match headers.get(COOKIE).and_then(|v| v.to_str().ok()) {
161 Some(c) => c,
162 None => return Ok(None),
163 };
164
165 let token =
166 match allowthem_core::parse_session_cookie(cookie_str, ath.session_config().cookie_name) {
167 Some(t) => t,
168 None => return Ok(None),
169 };
170
171 let session = match ath
172 .db()
173 .validate_session(&token, ath.session_config().ttl)
174 .await?
175 {
176 Some(s) => s,
177 None => return Ok(None),
178 };
179
180 match ath.db().get_user(session.user_id).await {
181 Ok(user) if user.is_active => Ok(Some(user)),
182 Ok(_) => Ok(None),
183 Err(AuthError::NotFound) => Ok(None),
184 Err(e) => Err(e),
185 }
186}
187
188pub async fn validate_authorize_params(
195 ath: &AllowThem,
196 params: &AuthorizeParams,
197) -> Result<ValidatedAuthorize, Response> {
198 let client_id = params
200 .client_id
201 .as_ref()
202 .ok_or_else(|| display_error(StatusCode::BAD_REQUEST, "missing client_id"))?;
203
204 let application = ath
205 .db()
206 .get_application_by_client_id(client_id)
207 .await
208 .map_err(|e| match e {
209 AuthError::NotFound => display_error(StatusCode::BAD_REQUEST, "unknown client_id"),
210 _ => display_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"),
211 })?;
212
213 if !application.is_active {
215 return Err(display_error(
216 StatusCode::BAD_REQUEST,
217 "application is inactive",
218 ));
219 }
220
221 let redirect_uri = params.redirect_uri.as_deref().unwrap_or("");
223 if redirect_uri.is_empty() {
224 return Err(display_error(
225 StatusCode::BAD_REQUEST,
226 "missing redirect_uri",
227 ));
228 }
229 let registered = application
230 .redirect_uri_list()
231 .map_err(|_| display_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
232 validate_redirect_uri(redirect_uri, ®istered)
233 .map_err(|_| display_error(StatusCode::BAD_REQUEST, "redirect_uri not registered"))?;
234
235 let redirect_uri = redirect_uri.to_string();
237
238 let state = match params.state.as_deref() {
240 Some(s) if !s.is_empty() => s.to_string(),
241 _ => {
242 return Err(error_redirect(
243 &redirect_uri,
244 OAuthErrorCode::InvalidRequest,
245 "missing state parameter",
246 "",
247 StatusCode::FOUND,
248 ));
249 }
250 };
251
252 if params.response_type.as_deref() != Some("code") {
254 return Err(error_redirect(
255 &redirect_uri,
256 OAuthErrorCode::UnsupportedResponseType,
257 "response_type must be code",
258 &state,
259 StatusCode::FOUND,
260 ));
261 }
262
263 let scope_str = params.scope.as_deref().unwrap_or("");
265 let scopes = validate_scopes(scope_str).map_err(|e| {
266 error_redirect(
267 &redirect_uri,
268 OAuthErrorCode::InvalidScope,
269 &e.to_string(),
270 &state,
271 StatusCode::FOUND,
272 )
273 })?;
274
275 let code_challenge = match params.code_challenge.as_deref() {
277 Some(c) if !c.is_empty() => c.to_string(),
278 _ => {
279 return Err(error_redirect(
280 &redirect_uri,
281 OAuthErrorCode::InvalidRequest,
282 "missing code_challenge (PKCE required)",
283 &state,
284 StatusCode::FOUND,
285 ));
286 }
287 };
288 let code_challenge_method = params.code_challenge_method.as_deref().unwrap_or("");
289 if code_challenge_method != "S256" {
290 return Err(error_redirect(
291 &redirect_uri,
292 OAuthErrorCode::InvalidRequest,
293 "code_challenge_method must be S256",
294 &state,
295 StatusCode::FOUND,
296 ));
297 }
298
299 Ok(ValidatedAuthorize {
300 application,
301 redirect_uri,
302 scopes,
303 state,
304 code_challenge,
305 code_challenge_method: "S256".to_string(),
306 nonce: params.nonce.clone(),
307 })
308}
309
310fn build_authorize_query_string(params: &AuthorizeParams) -> String {
316 let mut pairs = url::form_urlencoded::Serializer::new(String::new());
317 if let Some(ref v) = params.client_id {
318 pairs.append_pair("client_id", v.as_str());
319 }
320 if let Some(ref v) = params.redirect_uri {
321 pairs.append_pair("redirect_uri", v);
322 }
323 if let Some(ref v) = params.response_type {
324 pairs.append_pair("response_type", v);
325 }
326 if let Some(ref v) = params.scope {
327 pairs.append_pair("scope", v);
328 }
329 if let Some(ref v) = params.state {
330 pairs.append_pair("state", v);
331 }
332 if let Some(ref v) = params.code_challenge {
333 pairs.append_pair("code_challenge", v);
334 }
335 if let Some(ref v) = params.code_challenge_method {
336 pairs.append_pair("code_challenge_method", v);
337 }
338 if let Some(ref v) = params.nonce {
339 pairs.append_pair("nonce", v);
340 }
341 pairs.finish()
342}
343
344fn login_redirect(params: &AuthorizeParams) -> Response {
346 let full_uri = format!("/oauth/authorize?{}", build_authorize_query_string(params));
347 let encoded: String = url::form_urlencoded::byte_serialize(full_uri.as_bytes()).collect();
348 let mut redirect = format!("/login?next={encoded}");
349 if let Some(ref cid) = params.client_id {
350 redirect.push_str("&client_id=");
351 redirect.push_str(cid.as_str());
352 }
353 (StatusCode::SEE_OTHER, [("location", redirect)]).into_response()
354}
355
356pub async fn issue_code_and_redirect(
358 ath: &AllowThem,
359 validated: &ValidatedAuthorize,
360 user_id: UserId,
361 status: StatusCode,
362) -> Response {
363 let raw_code = generate_authorization_code();
364 let code_hash = hash_authorization_code(&raw_code);
365
366 match ath
367 .db()
368 .create_authorization_code(
369 validated.application.id,
370 user_id,
371 &code_hash,
372 &validated.redirect_uri,
373 &validated.scopes,
374 &validated.code_challenge,
375 &validated.code_challenge_method,
376 validated.nonce.as_deref(),
377 )
378 .await
379 {
380 Ok(_) => success_redirect(&validated.redirect_uri, &raw_code, &validated.state, status),
381 Err(_) => error_redirect(
382 &validated.redirect_uri,
383 OAuthErrorCode::ServerError,
384 "internal error",
385 &validated.state,
386 status,
387 ),
388 }
389}
390
391pub async fn check_authorization(
398 ath: &AllowThem,
399 headers: &HeaderMap,
400 params: &AuthorizeParams,
401) -> AuthorizeOutcome {
402 let validated = match validate_authorize_params(ath, params).await {
403 Ok(v) => v,
404 Err(resp) => return AuthorizeOutcome::Redirect(resp),
405 };
406
407 let user = match resolve_user(ath, headers).await {
409 Ok(Some(u)) => u,
410 Ok(None) => return AuthorizeOutcome::Redirect(login_redirect(params)),
411 Err(_) => {
412 return AuthorizeOutcome::Redirect(error_redirect(
413 &validated.redirect_uri,
414 OAuthErrorCode::ServerError,
415 "internal error",
416 &validated.state,
417 StatusCode::FOUND,
418 ));
419 }
420 };
421
422 let needs_consent = if validated.application.is_trusted {
424 false
425 } else {
426 match ath
427 .db()
428 .has_sufficient_consent(user.id, validated.application.id, &validated.scopes)
429 .await
430 {
431 Ok(has) => !has,
432 Err(_) => {
433 return AuthorizeOutcome::Redirect(error_redirect(
434 &validated.redirect_uri,
435 OAuthErrorCode::ServerError,
436 "internal error",
437 &validated.state,
438 StatusCode::FOUND,
439 ));
440 }
441 }
442 };
443
444 if needs_consent {
445 let context = ConsentContext {
446 branding: validated.application.branding(),
447 scopes: validated.scopes.clone(),
448 };
449 return AuthorizeOutcome::ConsentNeeded(Box::new(ConsentNeededData {
450 context,
451 params: validated,
452 user_email: user.email.as_str().to_string(),
453 }));
454 }
455
456 AuthorizeOutcome::Redirect(
458 issue_code_and_redirect(ath, &validated, user.id, StatusCode::FOUND).await,
459 )
460}
461
462pub async fn authorize_post(
463 Extension(ath): Extension<AllowThem>,
464 headers: HeaderMap,
465 Form(form): Form<ConsentSubmission>,
466) -> Response {
467 let params = AuthorizeParams {
469 client_id: form.client_id,
470 redirect_uri: form.redirect_uri,
471 response_type: form.response_type,
472 scope: form.scope,
473 state: form.state,
474 code_challenge: form.code_challenge,
475 code_challenge_method: form.code_challenge_method,
476 nonce: form.nonce,
477 };
478 let validated = match validate_authorize_params(&ath, ¶ms).await {
479 Ok(v) => v,
480 Err(resp) => return resp,
481 };
482
483 let user = match resolve_user(&ath, &headers).await {
485 Ok(Some(u)) => u,
486 Ok(None) => return login_redirect(¶ms),
487 Err(_) => {
488 return error_redirect(
489 &validated.redirect_uri,
490 OAuthErrorCode::ServerError,
491 "internal error",
492 &validated.state,
493 StatusCode::SEE_OTHER,
494 );
495 }
496 };
497
498 if form.consent != "approve" {
500 return error_redirect(
501 &validated.redirect_uri,
502 OAuthErrorCode::AccessDenied,
503 "user denied consent",
504 &validated.state,
505 StatusCode::SEE_OTHER,
506 );
507 }
508
509 if ath
511 .db()
512 .upsert_consent(user.id, validated.application.id, &validated.scopes)
513 .await
514 .is_err()
515 {
516 return error_redirect(
517 &validated.redirect_uri,
518 OAuthErrorCode::ServerError,
519 "internal error",
520 &validated.state,
521 StatusCode::SEE_OTHER,
522 );
523 }
524
525 issue_code_and_redirect(&ath, &validated, user.id, StatusCode::SEE_OTHER).await
527}
528
529#[cfg(test)]
538mod tests {
539 use super::*;
540 use allowthem_core::handle::AllowThemBuilder;
541 use allowthem_core::types::{ClientType, Email};
542 use axum::Router;
543 use axum::body::Body;
544 use axum::http::Request;
545 use axum::routing::post;
546 use tower::ServiceExt;
547
548 async fn test_ath() -> AllowThem {
549 AllowThemBuilder::new("sqlite::memory:")
550 .cookie_secure(false)
551 .build()
552 .await
553 .unwrap()
554 }
555
556 async fn setup_application(ath: &AllowThem) -> Application {
557 let email = Email::new("admin@example.com".into()).unwrap();
558 let user = ath
559 .db()
560 .create_user(email, "password123", None, None)
561 .await
562 .unwrap();
563
564 let (app, _) = ath
565 .db()
566 .create_application(CreateApplicationParams {
567 name: "TestApp".to_string(),
568 client_type: ClientType::Confidential,
569 redirect_uris: vec!["https://example.com/callback".to_string()],
570 is_trusted: false,
571 created_by: Some(user.id),
572 logo_url: None,
573 primary_color: None,
574 accent_hex: None,
575 accent_ink: None,
576 forced_mode: None,
577 font_css_url: None,
578 font_family: None,
579 splash_text: None,
580 splash_image_url: None,
581 splash_primitive: None,
582 splash_url: None,
583 shader_cell_scale: None,
584 })
585 .await
586 .unwrap();
587 app
588 }
589
590 fn authorize_params(app: &Application) -> AuthorizeParams {
591 AuthorizeParams {
592 client_id: Some(app.client_id.clone()),
593 redirect_uri: Some("https://example.com/callback".into()),
594 response_type: Some("code".into()),
595 scope: Some("openid profile".into()),
596 state: Some("xyz".into()),
597 code_challenge: Some("abc123".into()),
598 code_challenge_method: Some("S256".into()),
599 nonce: None,
600 }
601 }
602
603 fn expect_redirect(outcome: AuthorizeOutcome) -> Response {
605 match outcome {
606 AuthorizeOutcome::Redirect(resp) => resp,
607 AuthorizeOutcome::ConsentNeeded(_) => {
608 panic!("expected Redirect, got ConsentNeeded")
609 }
610 }
611 }
612
613 async fn read_body_html(resp: axum::http::Response<Body>) -> String {
614 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
615 .await
616 .unwrap();
617 String::from_utf8(bytes.to_vec()).unwrap()
618 }
619
620 async fn create_session(
622 ath: &AllowThem,
623 email: &str,
624 ) -> (allowthem_core::types::UserId, String) {
625 let email = Email::new(email.into()).unwrap();
626 let user = ath
627 .db()
628 .create_user(email, "password123", None, None)
629 .await
630 .unwrap();
631 let token = allowthem_core::generate_token();
632 let hash = allowthem_core::hash_token(&token);
633 let expires = chrono::Utc::now() + chrono::Duration::hours(24);
634 ath.db()
635 .create_session(user.id, hash, None, None, expires)
636 .await
637 .unwrap();
638 let cookie = format!("allowthem_session={}", token.as_str());
639 (user.id, cookie)
640 }
641
642 fn headers_with_cookie(cookie: &str) -> HeaderMap {
643 let mut headers = HeaderMap::new();
644 headers.insert("cookie", cookie.parse().unwrap());
645 headers
646 }
647
648 #[tokio::test]
651 async fn missing_client_id_returns_400() {
652 let ath = test_ath().await;
653 let params = AuthorizeParams {
654 client_id: None,
655 redirect_uri: Some("x".into()),
656 response_type: Some("code".into()),
657 scope: Some("openid".into()),
658 state: Some("s".into()),
659 code_challenge: Some("c".into()),
660 code_challenge_method: Some("S256".into()),
661 nonce: None,
662 };
663 let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), ¶ms).await);
664 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
665 let body = read_body_html(resp).await;
666 assert!(
667 body.contains("missing client_id"),
668 "expected error message in HTML body"
669 );
670 }
671
672 #[tokio::test]
673 async fn unknown_client_id_returns_400() {
674 let ath = test_ath().await;
675 let params = AuthorizeParams {
676 client_id: serde_json::from_value(serde_json::json!("ath_nonexistent")).ok(),
677 redirect_uri: Some("x".into()),
678 response_type: Some("code".into()),
679 scope: Some("openid".into()),
680 state: Some("s".into()),
681 code_challenge: Some("c".into()),
682 code_challenge_method: Some("S256".into()),
683 nonce: None,
684 };
685 let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), ¶ms).await);
686 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
687 let body = read_body_html(resp).await;
688 assert!(
689 body.contains("unknown client_id"),
690 "expected error message in HTML body"
691 );
692 }
693
694 #[tokio::test]
695 async fn unregistered_redirect_uri_returns_400() {
696 let ath = test_ath().await;
697 let application = setup_application(&ath).await;
698 let params = AuthorizeParams {
699 client_id: Some(application.client_id.clone()),
700 redirect_uri: Some("https://evil.example.com/callback".into()),
701 response_type: Some("code".into()),
702 scope: Some("openid".into()),
703 state: Some("s".into()),
704 code_challenge: Some("c".into()),
705 code_challenge_method: Some("S256".into()),
706 nonce: None,
707 };
708 let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), ¶ms).await);
709 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
710 let body = read_body_html(resp).await;
711 assert!(
712 body.contains("redirect_uri not registered"),
713 "expected error message in HTML body"
714 );
715 }
716
717 #[tokio::test]
720 async fn missing_state_redirects_with_error() {
721 let ath = test_ath().await;
722 let application = setup_application(&ath).await;
723 let params = AuthorizeParams {
724 client_id: Some(application.client_id.clone()),
725 redirect_uri: Some("https://example.com/callback".into()),
726 response_type: Some("code".into()),
727 scope: Some("openid".into()),
728 state: None,
729 code_challenge: Some("c".into()),
730 code_challenge_method: Some("S256".into()),
731 nonce: None,
732 };
733 let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), ¶ms).await);
734 assert_eq!(resp.status(), StatusCode::FOUND);
735 let location = resp.headers().get("location").unwrap().to_str().unwrap();
736 assert!(location.contains("error=invalid_request"));
737 }
738
739 #[tokio::test]
740 async fn bad_response_type_redirects_with_error() {
741 let ath = test_ath().await;
742 let application = setup_application(&ath).await;
743 let params = AuthorizeParams {
744 client_id: Some(application.client_id.clone()),
745 redirect_uri: Some("https://example.com/callback".into()),
746 response_type: Some("token".into()),
747 scope: Some("openid".into()),
748 state: Some("s".into()),
749 code_challenge: Some("c".into()),
750 code_challenge_method: Some("S256".into()),
751 nonce: None,
752 };
753 let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), ¶ms).await);
754 assert_eq!(resp.status(), StatusCode::FOUND);
755 let location = resp.headers().get("location").unwrap().to_str().unwrap();
756 assert!(location.contains("error=unsupported_response_type"));
757 assert!(location.contains("state=s"));
758 }
759
760 #[tokio::test]
761 async fn invalid_scope_redirects_with_error() {
762 let ath = test_ath().await;
763 let application = setup_application(&ath).await;
764 let params = AuthorizeParams {
765 client_id: Some(application.client_id.clone()),
766 redirect_uri: Some("https://example.com/callback".into()),
767 response_type: Some("code".into()),
768 scope: Some("profile".into()),
769 state: Some("s".into()),
770 code_challenge: Some("c".into()),
771 code_challenge_method: Some("S256".into()),
772 nonce: None,
773 };
774 let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), ¶ms).await);
775 assert_eq!(resp.status(), StatusCode::FOUND);
776 let location = resp.headers().get("location").unwrap().to_str().unwrap();
777 assert!(location.contains("error=invalid_scope"));
778 }
779
780 #[tokio::test]
781 async fn missing_pkce_redirects_with_error() {
782 let ath = test_ath().await;
783 let application = setup_application(&ath).await;
784 let params = AuthorizeParams {
785 client_id: Some(application.client_id.clone()),
786 redirect_uri: Some("https://example.com/callback".into()),
787 response_type: Some("code".into()),
788 scope: Some("openid".into()),
789 state: Some("s".into()),
790 code_challenge: None,
791 code_challenge_method: None,
792 nonce: None,
793 };
794 let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), ¶ms).await);
795 assert_eq!(resp.status(), StatusCode::FOUND);
796 let location = resp.headers().get("location").unwrap().to_str().unwrap();
797 assert!(location.contains("error=invalid_request"));
798 assert!(location.contains("PKCE"));
799 }
800
801 #[tokio::test]
804 async fn unauthenticated_redirects_to_login() {
805 let ath = test_ath().await;
806 let application = setup_application(&ath).await;
807 let params = authorize_params(&application);
808 let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), ¶ms).await);
809 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
810 let location = resp.headers().get("location").unwrap().to_str().unwrap();
811 assert!(location.starts_with("/login?next="));
812 assert!(location.contains("oauth%2Fauthorize"));
813 }
814
815 #[tokio::test]
818 async fn trusted_app_skips_consent_and_redirects_with_code() {
819 let ath = test_ath().await;
820 let (_, cookie) = create_session(&ath, "trusted@example.com").await;
821 let headers = headers_with_cookie(&cookie);
822
823 let (trusted_app, _) = ath
824 .db()
825 .create_application(CreateApplicationParams {
826 name: "TrustedApp".to_string(),
827 client_type: ClientType::Confidential,
828 redirect_uris: vec!["https://trusted.example.com/callback".to_string()],
829 is_trusted: true,
830 created_by: None,
831 logo_url: None,
832 primary_color: None,
833 accent_hex: None,
834 accent_ink: None,
835 forced_mode: None,
836 font_css_url: None,
837 font_family: None,
838 splash_text: None,
839 splash_image_url: None,
840 splash_primitive: None,
841 splash_url: None,
842 shader_cell_scale: None,
843 })
844 .await
845 .unwrap();
846
847 let params = AuthorizeParams {
848 client_id: Some(trusted_app.client_id.clone()),
849 redirect_uri: Some("https://trusted.example.com/callback".into()),
850 response_type: Some("code".into()),
851 scope: Some("openid profile".into()),
852 state: Some("xyz".into()),
853 code_challenge: Some("abc123".into()),
854 code_challenge_method: Some("S256".into()),
855 nonce: None,
856 };
857
858 let resp = expect_redirect(check_authorization(&ath, &headers, ¶ms).await);
859 assert_eq!(resp.status(), StatusCode::FOUND);
860 let location = resp.headers().get("location").unwrap().to_str().unwrap();
861 assert!(location.contains("code="));
862 assert!(location.contains("state=xyz"));
863 assert!(location.starts_with("https://trusted.example.com/callback"));
864 }
865
866 #[tokio::test]
869 async fn untrusted_app_without_consent_returns_consent_needed() {
870 let ath = test_ath().await;
871 let (_, cookie) = create_session(&ath, "consent@example.com").await;
872 let headers = headers_with_cookie(&cookie);
873 let application = setup_application(&ath).await;
874 let params = authorize_params(&application);
875
876 let outcome = check_authorization(&ath, &headers, ¶ms).await;
877 match outcome {
878 AuthorizeOutcome::ConsentNeeded(data) => {
879 assert_eq!(data.context.branding.application_name, "TestApp");
880 assert_eq!(data.context.scopes, vec!["openid", "profile"]);
881 }
882 AuthorizeOutcome::Redirect(_) => panic!("expected ConsentNeeded, got Redirect"),
883 }
884 }
885
886 #[tokio::test]
889 async fn inactive_application_returns_400() {
890 let ath = test_ath().await;
891 let application = setup_application(&ath).await;
892
893 sqlx::query("UPDATE allowthem_applications SET is_active = 0 WHERE id = ?")
894 .bind(application.id)
895 .execute(ath.db().pool())
896 .await
897 .unwrap();
898
899 let params = AuthorizeParams {
900 client_id: Some(application.client_id.clone()),
901 redirect_uri: Some("https://example.com/callback".into()),
902 response_type: Some("code".into()),
903 scope: Some("openid".into()),
904 state: Some("s".into()),
905 code_challenge: Some("c".into()),
906 code_challenge_method: Some("S256".into()),
907 nonce: None,
908 };
909 let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), ¶ms).await);
910 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
911 let body = read_body_html(resp).await;
912 assert!(
913 body.contains("application is inactive"),
914 "expected error message in HTML body"
915 );
916 }
917
918 #[tokio::test]
921 async fn wrong_pkce_method_redirects_with_error() {
922 let ath = test_ath().await;
923 let application = setup_application(&ath).await;
924 let params = AuthorizeParams {
925 client_id: Some(application.client_id.clone()),
926 redirect_uri: Some("https://example.com/callback".into()),
927 response_type: Some("code".into()),
928 scope: Some("openid".into()),
929 state: Some("s".into()),
930 code_challenge: Some("c".into()),
931 code_challenge_method: Some("plain".into()),
932 nonce: None,
933 };
934 let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), ¶ms).await);
935 assert_eq!(resp.status(), StatusCode::FOUND);
936 let location = resp.headers().get("location").unwrap().to_str().unwrap();
937 assert!(location.contains("error=invalid_request"));
938 assert!(location.contains("state=s"));
939 }
940
941 #[tokio::test]
944 async fn existing_consent_skips_consent_screen() {
945 let ath = test_ath().await;
946 let (user_id, cookie) = create_session(&ath, "existing_consent@example.com").await;
947 let headers = headers_with_cookie(&cookie);
948 let application = setup_application(&ath).await;
949
950 ath.db()
951 .upsert_consent(
952 user_id,
953 application.id,
954 &["openid".to_string(), "profile".to_string()],
955 )
956 .await
957 .unwrap();
958
959 let params = authorize_params(&application);
960 let resp = expect_redirect(check_authorization(&ath, &headers, ¶ms).await);
961 assert_eq!(resp.status(), StatusCode::FOUND);
962 let location = resp.headers().get("location").unwrap().to_str().unwrap();
963 assert!(location.contains("code="));
964 assert!(location.contains("state=xyz"));
965 }
966
967 fn post_app(ath: AllowThem) -> Router {
970 Router::new()
971 .route("/oauth/authorize", post(authorize_post))
972 .layer(axum::middleware::from_fn_with_state(
973 ath,
974 crate::cors::inject_ath_into_extensions,
975 ))
976 }
977
978 #[tokio::test]
979 async fn post_approve_creates_code_and_redirects_303() {
980 let ath = test_ath().await;
981 let app = post_app(ath.clone());
982 let (_, cookie) = create_session(&ath, "post_approve@example.com").await;
983 let application = setup_application(&ath).await;
984
985 let body = url::form_urlencoded::Serializer::new(String::new())
986 .append_pair("client_id", application.client_id.as_str())
987 .append_pair("redirect_uri", "https://example.com/callback")
988 .append_pair("response_type", "code")
989 .append_pair("scope", "openid profile")
990 .append_pair("state", "mystate")
991 .append_pair("code_challenge", "mychallenge")
992 .append_pair("code_challenge_method", "S256")
993 .append_pair("consent", "approve")
994 .finish();
995
996 let req = Request::builder()
997 .method("POST")
998 .uri("/oauth/authorize")
999 .header("cookie", &cookie)
1000 .header("content-type", "application/x-www-form-urlencoded")
1001 .body(Body::from(body))
1002 .unwrap();
1003 let resp = app.oneshot(req).await.unwrap();
1004 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1005 let location = resp.headers().get("location").unwrap().to_str().unwrap();
1006 assert!(location.starts_with("https://example.com/callback"));
1007 assert!(location.contains("code="));
1008 assert!(location.contains("state=mystate"));
1009 }
1010
1011 #[tokio::test]
1012 async fn post_deny_redirects_with_access_denied_303() {
1013 let ath = test_ath().await;
1014 let app = post_app(ath.clone());
1015 let (_, cookie) = create_session(&ath, "post_deny@example.com").await;
1016 let application = setup_application(&ath).await;
1017
1018 let body = url::form_urlencoded::Serializer::new(String::new())
1019 .append_pair("client_id", application.client_id.as_str())
1020 .append_pair("redirect_uri", "https://example.com/callback")
1021 .append_pair("response_type", "code")
1022 .append_pair("scope", "openid profile")
1023 .append_pair("state", "mystate")
1024 .append_pair("code_challenge", "mychallenge")
1025 .append_pair("code_challenge_method", "S256")
1026 .append_pair("consent", "deny")
1027 .finish();
1028
1029 let req = Request::builder()
1030 .method("POST")
1031 .uri("/oauth/authorize")
1032 .header("cookie", &cookie)
1033 .header("content-type", "application/x-www-form-urlencoded")
1034 .body(Body::from(body))
1035 .unwrap();
1036 let resp = app.oneshot(req).await.unwrap();
1037 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1038 let location = resp.headers().get("location").unwrap().to_str().unwrap();
1039 assert!(location.contains("error=access_denied"));
1040 assert!(location.contains("state=mystate"));
1041 }
1042
1043 #[tokio::test]
1044 async fn post_unauthenticated_redirects_to_login() {
1045 let ath = test_ath().await;
1046 let app = post_app(ath.clone());
1047 let application = setup_application(&ath).await;
1048
1049 let body = url::form_urlencoded::Serializer::new(String::new())
1050 .append_pair("client_id", application.client_id.as_str())
1051 .append_pair("redirect_uri", "https://example.com/callback")
1052 .append_pair("response_type", "code")
1053 .append_pair("scope", "openid")
1054 .append_pair("state", "s")
1055 .append_pair("code_challenge", "c")
1056 .append_pair("code_challenge_method", "S256")
1057 .append_pair("consent", "approve")
1058 .finish();
1059
1060 let req = Request::builder()
1061 .method("POST")
1062 .uri("/oauth/authorize")
1063 .header("content-type", "application/x-www-form-urlencoded")
1064 .body(Body::from(body))
1065 .unwrap();
1066 let resp = app.oneshot(req).await.unwrap();
1067 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1068 let location = resp.headers().get("location").unwrap().to_str().unwrap();
1069 assert!(location.starts_with("/login?next="));
1070 }
1071
1072 #[tokio::test]
1073 async fn post_with_invalid_client_id_returns_400() {
1074 let ath = test_ath().await;
1075 let app = post_app(ath.clone());
1076 let (_, cookie) = create_session(&ath, "post_revalidate@example.com").await;
1077
1078 let body = url::form_urlencoded::Serializer::new(String::new())
1079 .append_pair("client_id", "ath_nonexistent")
1080 .append_pair("redirect_uri", "https://example.com/callback")
1081 .append_pair("response_type", "code")
1082 .append_pair("scope", "openid")
1083 .append_pair("state", "s")
1084 .append_pair("code_challenge", "c")
1085 .append_pair("code_challenge_method", "S256")
1086 .append_pair("consent", "approve")
1087 .finish();
1088
1089 let req = Request::builder()
1090 .method("POST")
1091 .uri("/oauth/authorize")
1092 .header("cookie", &cookie)
1093 .header("content-type", "application/x-www-form-urlencoded")
1094 .body(Body::from(body))
1095 .unwrap();
1096 let resp = app.oneshot(req).await.unwrap();
1097 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1098 let body = read_body_html(resp).await;
1099 assert!(
1100 body.contains("unknown client_id"),
1101 "expected error message in HTML body"
1102 );
1103 }
1104}