Skip to main content

allowthem_server/
authorize_routes.rs

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
18// ---------------------------------------------------------------------------
19// OAuth2 error codes (RFC 6749 Section 4.1.2.1)
20// ---------------------------------------------------------------------------
21
22enum 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
42// ---------------------------------------------------------------------------
43// Request / response types
44// ---------------------------------------------------------------------------
45
46/// Result of the full authorization check: either a redirect response
47/// or a signal that the consent screen should be rendered.
48pub enum AuthorizeOutcome {
49    /// Redirect the user (success with code, error, or login redirect).
50    Redirect(Response),
51    /// Consent is needed — render the consent screen.
52    ConsentNeeded(Box<ConsentNeededData>),
53}
54
55pub struct ConsentNeededData {
56    pub context: ConsentContext,
57    pub params: ValidatedAuthorize,
58    pub user_email: String,
59}
60
61/// Query parameters for GET /oauth/authorize.
62/// All fields are Option so we can produce specific error messages for each.
63/// RF-2: client_id is `Option<ClientId>` — ClientId derives Deserialize,
64/// so Axum deserializes it directly without needing `new_unchecked`.
65#[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/// Form body for POST /oauth/authorize (consent submission).
78#[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    /// CSRF token — validated by the CSRF middleware layer before this handler.
90    #[allow(dead_code)]
91    csrf_token: Option<String>,
92}
93
94/// Data for the consent screen. M39 produces this; M40 renders it.
95pub struct ConsentContext {
96    pub branding: BrandingConfig,
97    pub scopes: Vec<String>,
98}
99
100/// Validated parameters after all authorization checks pass.
101pub 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
111// ---------------------------------------------------------------------------
112// Redirect and error helpers
113// ---------------------------------------------------------------------------
114
115/// Build a successful authorization redirect: `redirect_uri?code=...&state=...`
116fn 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
124/// Build an error redirect: `redirect_uri?error=...&error_description=...&state=...`
125fn 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
140/// Build a styled display error response (shown to user, not redirected).
141///
142/// Used for authorization errors before a valid redirect_uri is established
143/// (steps 1-3: missing/unknown client_id, inactive app, bad redirect_uri).
144fn 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
149// ---------------------------------------------------------------------------
150// Session resolution (RF-1: correct pattern from oauth_routes.rs:360-420)
151// ---------------------------------------------------------------------------
152
153/// Resolve the authenticated user from session cookie, or None if not authenticated.
154/// Uses the same pattern as `require_session` in oauth_routes.rs:
155/// session_config().cookie_name -> db().validate_session() -> db().get_user() -> is_active check
156pub 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
188// ---------------------------------------------------------------------------
189// Shared validation
190// ---------------------------------------------------------------------------
191
192/// Validate authorization request parameters (steps 1-7 from the spec).
193/// Steps 1-3 return display errors. Steps 4-7 return redirect errors.
194pub async fn validate_authorize_params(
195    ath: &AllowThem,
196    params: &AuthorizeParams,
197) -> Result<ValidatedAuthorize, Response> {
198    // Step 1: Validate client_id
199    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    // Step 2: Validate application is active
214    if !application.is_active {
215        return Err(display_error(
216            StatusCode::BAD_REQUEST,
217            "application is inactive",
218        ));
219    }
220
221    // Step 3: Validate redirect_uri
222    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, &registered)
233        .map_err(|_| display_error(StatusCode::BAD_REQUEST, "redirect_uri not registered"))?;
234
235    // From here, redirect_uri is trusted — errors redirect to it.
236    let redirect_uri = redirect_uri.to_string();
237
238    // Step 4: Validate state (required — new provider, no legacy clients)
239    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    // Step 5: Validate response_type
253    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    // Step 6: Validate scope
264    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    // Step 7: Validate PKCE
276    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
310// ---------------------------------------------------------------------------
311// Helpers
312// ---------------------------------------------------------------------------
313
314/// Build a query string from authorize params for the login redirect.
315fn 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
344/// Build a login redirect preserving the full authorize URL in ?next=
345fn 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
356/// Generate an authorization code, store it, and redirect with code+state.
357pub 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
391// ---------------------------------------------------------------------------
392// Authorization check (used by binaries/consent.rs GET handler)
393// ---------------------------------------------------------------------------
394
395/// Run the full authorization flow: validate params, check session,
396/// check consent, and either produce a redirect or signal consent needed.
397pub 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    // Check if user is authenticated
408    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    // Check consent
423    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    // Consent exists or app is trusted — generate code and redirect
457    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    // Re-validate all authorization parameters (defense-in-depth)
468    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, &params).await {
479        Ok(v) => v,
480        Err(resp) => return resp,
481    };
482
483    // Verify user is authenticated (RF-1: correct session resolution pattern)
484    let user = match resolve_user(&ath, &headers).await {
485        Ok(Some(u)) => u,
486        Ok(None) => return login_redirect(&params),
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    // Handle consent decision
499    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    // Upsert consent
510    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    // Generate code and redirect (POST uses 303 See Other)
526    issue_code_and_redirect(&ath, &validated, user.id, StatusCode::SEE_OTHER).await
527}
528
529// ---------------------------------------------------------------------------
530// Handlers
531// ---------------------------------------------------------------------------
532
533// ---------------------------------------------------------------------------
534// Tests
535// ---------------------------------------------------------------------------
536
537#[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    /// Extract a redirect response from AuthorizeOutcome, panicking if consent.
604    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    // Helper: create a user, session, and return (user_id, session_cookie_header)
621    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    // Display error tests (steps 1-3)
649
650    #[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(), &params).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(), &params).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(), &params).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    // Redirect error tests (steps 4-7)
718
719    #[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(), &params).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(), &params).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(), &params).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(), &params).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    // Unauthenticated user redirects to login
802
803    #[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(), &params).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    // Authenticated user with trusted app skips consent
816
817    #[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, &params).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    // Authenticated user without consent gets ConsentNeeded
867
868    #[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, &params).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    // Inactive application returns display error
887
888    #[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(), &params).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    // Wrong code_challenge_method redirects with error
919
920    #[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(), &params).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    // Existing consent skips the consent screen
942
943    #[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, &params).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    // POST handler tests — use a minimal router with just post(authorize_post)
968
969    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}