Skip to main content

allowthem_server/
authorize_routes.rs

1use axum::extract::State;
2use axum::http::header::COOKIE;
3use axum::http::{HeaderMap, StatusCode};
4use axum::response::{IntoResponse, Response};
5use axum::{Form, Json};
6use serde::Deserialize;
7use serde_json::json;
8use url::Url;
9
10use allowthem_core::applications::{Application, BrandingConfig, validate_redirect_uri};
11use allowthem_core::authorization::{
12    generate_authorization_code, hash_authorization_code, validate_scopes,
13};
14use allowthem_core::types::{ClientId, UserId};
15use allowthem_core::{AllowThem, AuthError};
16
17// ---------------------------------------------------------------------------
18// OAuth2 error codes (RFC 6749 Section 4.1.2.1)
19// ---------------------------------------------------------------------------
20
21enum OAuthErrorCode {
22    InvalidRequest,
23    AccessDenied,
24    UnsupportedResponseType,
25    InvalidScope,
26    ServerError,
27}
28
29impl OAuthErrorCode {
30    fn as_str(&self) -> &'static str {
31        match self {
32            Self::InvalidRequest => "invalid_request",
33            Self::AccessDenied => "access_denied",
34            Self::UnsupportedResponseType => "unsupported_response_type",
35            Self::InvalidScope => "invalid_scope",
36            Self::ServerError => "server_error",
37        }
38    }
39}
40
41// ---------------------------------------------------------------------------
42// Request / response types
43// ---------------------------------------------------------------------------
44
45/// Result of the full authorization check: either a redirect response
46/// or a signal that the consent screen should be rendered.
47pub enum AuthorizeOutcome {
48    /// Redirect the user (success with code, error, or login redirect).
49    Redirect(Response),
50    /// Consent is needed — render the consent screen.
51    ConsentNeeded(Box<ConsentNeededData>),
52}
53
54pub struct ConsentNeededData {
55    pub context: ConsentContext,
56    pub params: ValidatedAuthorize,
57}
58
59/// Query parameters for GET /oauth/authorize.
60/// All fields are Option so we can produce specific error messages for each.
61/// RF-2: client_id is `Option<ClientId>` — ClientId derives Deserialize,
62/// so Axum deserializes it directly without needing `new_unchecked`.
63#[derive(Deserialize)]
64pub struct AuthorizeParams {
65    pub client_id: Option<ClientId>,
66    pub redirect_uri: Option<String>,
67    pub response_type: Option<String>,
68    pub scope: Option<String>,
69    pub state: Option<String>,
70    pub code_challenge: Option<String>,
71    pub code_challenge_method: Option<String>,
72    pub nonce: Option<String>,
73}
74
75/// Form body for POST /oauth/authorize (consent submission).
76#[derive(Deserialize)]
77pub struct ConsentSubmission {
78    client_id: Option<ClientId>,
79    redirect_uri: Option<String>,
80    response_type: Option<String>,
81    scope: Option<String>,
82    state: Option<String>,
83    code_challenge: Option<String>,
84    code_challenge_method: Option<String>,
85    nonce: Option<String>,
86    consent: String,
87    /// CSRF token — validated by the CSRF middleware layer before this handler.
88    #[allow(dead_code)]
89    csrf_token: Option<String>,
90}
91
92/// Data for the consent screen. M39 produces this; M40 renders it.
93pub struct ConsentContext {
94    pub branding: BrandingConfig,
95    pub scopes: Vec<String>,
96}
97
98/// Validated parameters after all authorization checks pass.
99pub struct ValidatedAuthorize {
100    pub application: Application,
101    pub redirect_uri: String,
102    pub scopes: Vec<String>,
103    pub state: String,
104    pub code_challenge: String,
105    pub code_challenge_method: String,
106    pub nonce: Option<String>,
107}
108
109// ---------------------------------------------------------------------------
110// Redirect and error helpers
111// ---------------------------------------------------------------------------
112
113/// Build a successful authorization redirect: `redirect_uri?code=...&state=...`
114fn success_redirect(redirect_uri: &str, code: &str, state: &str, status: StatusCode) -> Response {
115    let mut url = Url::parse(redirect_uri).expect("redirect_uri was pre-validated");
116    url.query_pairs_mut()
117        .append_pair("code", code)
118        .append_pair("state", state);
119    (status, [("location", url.as_str().to_string())]).into_response()
120}
121
122/// Build an error redirect: `redirect_uri?error=...&error_description=...&state=...`
123fn error_redirect(
124    redirect_uri: &str,
125    error: OAuthErrorCode,
126    description: &str,
127    state: &str,
128    status: StatusCode,
129) -> Response {
130    let mut url = Url::parse(redirect_uri).expect("redirect_uri was pre-validated");
131    url.query_pairs_mut()
132        .append_pair("error", error.as_str())
133        .append_pair("error_description", description)
134        .append_pair("state", state);
135    (status, [("location", url.as_str().to_string())]).into_response()
136}
137
138/// Build a display error response (shown to user, not redirected).
139fn display_error(status: StatusCode, message: &str) -> Response {
140    (status, Json(json!({"error": message}))).into_response()
141}
142
143// ---------------------------------------------------------------------------
144// Session resolution (RF-1: correct pattern from oauth_routes.rs:360-420)
145// ---------------------------------------------------------------------------
146
147/// Resolve the authenticated user from session cookie, or None if not authenticated.
148/// Uses the same pattern as `require_session` in oauth_routes.rs:
149/// session_config().cookie_name -> db().validate_session() -> db().get_user() -> is_active check
150pub async fn resolve_user(
151    ath: &AllowThem,
152    headers: &HeaderMap,
153) -> Result<Option<allowthem_core::User>, AuthError> {
154    let cookie_str = match headers.get(COOKIE).and_then(|v| v.to_str().ok()) {
155        Some(c) => c,
156        None => return Ok(None),
157    };
158
159    let token =
160        match allowthem_core::parse_session_cookie(cookie_str, ath.session_config().cookie_name) {
161            Some(t) => t,
162            None => return Ok(None),
163        };
164
165    let session = match ath
166        .db()
167        .validate_session(&token, ath.session_config().ttl)
168        .await?
169    {
170        Some(s) => s,
171        None => return Ok(None),
172    };
173
174    match ath.db().get_user(session.user_id).await {
175        Ok(user) if user.is_active => Ok(Some(user)),
176        Ok(_) => Ok(None),
177        Err(AuthError::NotFound) => Ok(None),
178        Err(e) => Err(e),
179    }
180}
181
182// ---------------------------------------------------------------------------
183// Shared validation
184// ---------------------------------------------------------------------------
185
186/// Validate authorization request parameters (steps 1-7 from the spec).
187/// Steps 1-3 return display errors. Steps 4-7 return redirect errors.
188pub async fn validate_authorize_params(
189    ath: &AllowThem,
190    params: &AuthorizeParams,
191) -> Result<ValidatedAuthorize, Response> {
192    // Step 1: Validate client_id
193    let client_id = params
194        .client_id
195        .as_ref()
196        .ok_or_else(|| display_error(StatusCode::BAD_REQUEST, "missing client_id"))?;
197
198    let application = ath
199        .db()
200        .get_application_by_client_id(client_id)
201        .await
202        .map_err(|e| match e {
203            AuthError::NotFound => display_error(StatusCode::BAD_REQUEST, "unknown client_id"),
204            _ => display_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"),
205        })?;
206
207    // Step 2: Validate application is active
208    if !application.is_active {
209        return Err(display_error(
210            StatusCode::BAD_REQUEST,
211            "application is inactive",
212        ));
213    }
214
215    // Step 3: Validate redirect_uri
216    let redirect_uri = params.redirect_uri.as_deref().unwrap_or("");
217    if redirect_uri.is_empty() {
218        return Err(display_error(
219            StatusCode::BAD_REQUEST,
220            "missing redirect_uri",
221        ));
222    }
223    let registered = application
224        .redirect_uri_list()
225        .map_err(|_| display_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
226    validate_redirect_uri(redirect_uri, &registered)
227        .map_err(|_| display_error(StatusCode::BAD_REQUEST, "redirect_uri not registered"))?;
228
229    // From here, redirect_uri is trusted — errors redirect to it.
230    let redirect_uri = redirect_uri.to_string();
231
232    // Step 4: Validate state (required — new provider, no legacy clients)
233    let state = match params.state.as_deref() {
234        Some(s) if !s.is_empty() => s.to_string(),
235        _ => {
236            return Err(error_redirect(
237                &redirect_uri,
238                OAuthErrorCode::InvalidRequest,
239                "missing state parameter",
240                "",
241                StatusCode::FOUND,
242            ));
243        }
244    };
245
246    // Step 5: Validate response_type
247    if params.response_type.as_deref() != Some("code") {
248        return Err(error_redirect(
249            &redirect_uri,
250            OAuthErrorCode::UnsupportedResponseType,
251            "response_type must be code",
252            &state,
253            StatusCode::FOUND,
254        ));
255    }
256
257    // Step 6: Validate scope
258    let scope_str = params.scope.as_deref().unwrap_or("");
259    let scopes = validate_scopes(scope_str).map_err(|e| {
260        error_redirect(
261            &redirect_uri,
262            OAuthErrorCode::InvalidScope,
263            &e.to_string(),
264            &state,
265            StatusCode::FOUND,
266        )
267    })?;
268
269    // Step 7: Validate PKCE
270    let code_challenge = match params.code_challenge.as_deref() {
271        Some(c) if !c.is_empty() => c.to_string(),
272        _ => {
273            return Err(error_redirect(
274                &redirect_uri,
275                OAuthErrorCode::InvalidRequest,
276                "missing code_challenge (PKCE required)",
277                &state,
278                StatusCode::FOUND,
279            ));
280        }
281    };
282    let code_challenge_method = params.code_challenge_method.as_deref().unwrap_or("");
283    if code_challenge_method != "S256" {
284        return Err(error_redirect(
285            &redirect_uri,
286            OAuthErrorCode::InvalidRequest,
287            "code_challenge_method must be S256",
288            &state,
289            StatusCode::FOUND,
290        ));
291    }
292
293    Ok(ValidatedAuthorize {
294        application,
295        redirect_uri,
296        scopes,
297        state,
298        code_challenge,
299        code_challenge_method: "S256".to_string(),
300        nonce: params.nonce.clone(),
301    })
302}
303
304// ---------------------------------------------------------------------------
305// Helpers
306// ---------------------------------------------------------------------------
307
308/// Build a query string from authorize params for the login redirect.
309fn build_authorize_query_string(params: &AuthorizeParams) -> String {
310    let mut pairs = url::form_urlencoded::Serializer::new(String::new());
311    if let Some(ref v) = params.client_id {
312        pairs.append_pair("client_id", v.as_str());
313    }
314    if let Some(ref v) = params.redirect_uri {
315        pairs.append_pair("redirect_uri", v);
316    }
317    if let Some(ref v) = params.response_type {
318        pairs.append_pair("response_type", v);
319    }
320    if let Some(ref v) = params.scope {
321        pairs.append_pair("scope", v);
322    }
323    if let Some(ref v) = params.state {
324        pairs.append_pair("state", v);
325    }
326    if let Some(ref v) = params.code_challenge {
327        pairs.append_pair("code_challenge", v);
328    }
329    if let Some(ref v) = params.code_challenge_method {
330        pairs.append_pair("code_challenge_method", v);
331    }
332    if let Some(ref v) = params.nonce {
333        pairs.append_pair("nonce", v);
334    }
335    pairs.finish()
336}
337
338/// Build a login redirect preserving the full authorize URL in ?next=
339fn login_redirect(params: &AuthorizeParams) -> Response {
340    let full_uri = format!("/oauth/authorize?{}", build_authorize_query_string(params));
341    let encoded: String = url::form_urlencoded::byte_serialize(full_uri.as_bytes()).collect();
342    let mut redirect = format!("/login?next={encoded}");
343    if let Some(ref cid) = params.client_id {
344        redirect.push_str("&client_id=");
345        redirect.push_str(cid.as_str());
346    }
347    (StatusCode::SEE_OTHER, [("location", redirect)]).into_response()
348}
349
350/// Generate an authorization code, store it, and redirect with code+state.
351pub async fn issue_code_and_redirect(
352    ath: &AllowThem,
353    validated: &ValidatedAuthorize,
354    user_id: UserId,
355    status: StatusCode,
356) -> Response {
357    let raw_code = generate_authorization_code();
358    let code_hash = hash_authorization_code(&raw_code);
359
360    match ath
361        .db()
362        .create_authorization_code(
363            validated.application.id,
364            user_id,
365            &code_hash,
366            &validated.redirect_uri,
367            &validated.scopes,
368            &validated.code_challenge,
369            &validated.code_challenge_method,
370            validated.nonce.as_deref(),
371        )
372        .await
373    {
374        Ok(_) => success_redirect(&validated.redirect_uri, &raw_code, &validated.state, status),
375        Err(_) => error_redirect(
376            &validated.redirect_uri,
377            OAuthErrorCode::ServerError,
378            "internal error",
379            &validated.state,
380            status,
381        ),
382    }
383}
384
385// ---------------------------------------------------------------------------
386// Authorization check (used by binaries/consent.rs GET handler)
387// ---------------------------------------------------------------------------
388
389/// Run the full authorization flow: validate params, check session,
390/// check consent, and either produce a redirect or signal consent needed.
391pub async fn check_authorization(
392    ath: &AllowThem,
393    headers: &HeaderMap,
394    params: &AuthorizeParams,
395) -> AuthorizeOutcome {
396    let validated = match validate_authorize_params(ath, params).await {
397        Ok(v) => v,
398        Err(resp) => return AuthorizeOutcome::Redirect(resp),
399    };
400
401    // Check if user is authenticated
402    let user = match resolve_user(ath, headers).await {
403        Ok(Some(u)) => u,
404        Ok(None) => return AuthorizeOutcome::Redirect(login_redirect(params)),
405        Err(_) => {
406            return AuthorizeOutcome::Redirect(error_redirect(
407                &validated.redirect_uri,
408                OAuthErrorCode::ServerError,
409                "internal error",
410                &validated.state,
411                StatusCode::FOUND,
412            ));
413        }
414    };
415
416    // Check consent
417    let needs_consent = if validated.application.is_trusted {
418        false
419    } else {
420        match ath
421            .db()
422            .has_sufficient_consent(user.id, validated.application.id, &validated.scopes)
423            .await
424        {
425            Ok(has) => !has,
426            Err(_) => {
427                return AuthorizeOutcome::Redirect(error_redirect(
428                    &validated.redirect_uri,
429                    OAuthErrorCode::ServerError,
430                    "internal error",
431                    &validated.state,
432                    StatusCode::FOUND,
433                ));
434            }
435        }
436    };
437
438    if needs_consent {
439        let context = ConsentContext {
440            branding: validated.application.branding(),
441            scopes: validated.scopes.clone(),
442        };
443        return AuthorizeOutcome::ConsentNeeded(Box::new(ConsentNeededData {
444            context,
445            params: validated,
446        }));
447    }
448
449    // Consent exists or app is trusted — generate code and redirect
450    AuthorizeOutcome::Redirect(
451        issue_code_and_redirect(ath, &validated, user.id, StatusCode::FOUND).await,
452    )
453}
454
455pub async fn authorize_post(
456    State(ath): State<AllowThem>,
457    headers: HeaderMap,
458    Form(form): Form<ConsentSubmission>,
459) -> Response {
460    // Re-validate all authorization parameters (defense-in-depth)
461    let params = AuthorizeParams {
462        client_id: form.client_id,
463        redirect_uri: form.redirect_uri,
464        response_type: form.response_type,
465        scope: form.scope,
466        state: form.state,
467        code_challenge: form.code_challenge,
468        code_challenge_method: form.code_challenge_method,
469        nonce: form.nonce,
470    };
471    let validated = match validate_authorize_params(&ath, &params).await {
472        Ok(v) => v,
473        Err(resp) => return resp,
474    };
475
476    // Verify user is authenticated (RF-1: correct session resolution pattern)
477    let user = match resolve_user(&ath, &headers).await {
478        Ok(Some(u)) => u,
479        Ok(None) => return login_redirect(&params),
480        Err(_) => {
481            return error_redirect(
482                &validated.redirect_uri,
483                OAuthErrorCode::ServerError,
484                "internal error",
485                &validated.state,
486                StatusCode::SEE_OTHER,
487            );
488        }
489    };
490
491    // Handle consent decision
492    if form.consent != "approve" {
493        return error_redirect(
494            &validated.redirect_uri,
495            OAuthErrorCode::AccessDenied,
496            "user denied consent",
497            &validated.state,
498            StatusCode::SEE_OTHER,
499        );
500    }
501
502    // Upsert consent
503    if ath
504        .db()
505        .upsert_consent(user.id, validated.application.id, &validated.scopes)
506        .await
507        .is_err()
508    {
509        return error_redirect(
510            &validated.redirect_uri,
511            OAuthErrorCode::ServerError,
512            "internal error",
513            &validated.state,
514            StatusCode::SEE_OTHER,
515        );
516    }
517
518    // Generate code and redirect (POST uses 303 See Other)
519    issue_code_and_redirect(&ath, &validated, user.id, StatusCode::SEE_OTHER).await
520}
521
522// ---------------------------------------------------------------------------
523// Handlers
524// ---------------------------------------------------------------------------
525
526// ---------------------------------------------------------------------------
527// Tests
528// ---------------------------------------------------------------------------
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use allowthem_core::handle::AllowThemBuilder;
534    use allowthem_core::types::Email;
535    use axum::Router;
536    use axum::body::Body;
537    use axum::http::Request;
538    use axum::routing::post;
539    use tower::ServiceExt;
540
541    async fn test_ath() -> AllowThem {
542        AllowThemBuilder::new("sqlite::memory:")
543            .cookie_secure(false)
544            .build()
545            .await
546            .unwrap()
547    }
548
549    async fn setup_application(ath: &AllowThem) -> Application {
550        let email = Email::new("admin@example.com".into()).unwrap();
551        let user = ath
552            .db()
553            .create_user(email, "password123", None, None)
554            .await
555            .unwrap();
556
557        let (app, _) = ath
558            .db()
559            .create_application(
560                "TestApp".to_string(),
561                vec!["https://example.com/callback".to_string()],
562                false,
563                Some(user.id),
564                None,
565                None,
566            )
567            .await
568            .unwrap();
569        app
570    }
571
572    fn authorize_params(app: &Application) -> AuthorizeParams {
573        AuthorizeParams {
574            client_id: Some(app.client_id.clone()),
575            redirect_uri: Some("https://example.com/callback".into()),
576            response_type: Some("code".into()),
577            scope: Some("openid profile".into()),
578            state: Some("xyz".into()),
579            code_challenge: Some("abc123".into()),
580            code_challenge_method: Some("S256".into()),
581            nonce: None,
582        }
583    }
584
585    /// Extract a redirect response from AuthorizeOutcome, panicking if consent.
586    fn expect_redirect(outcome: AuthorizeOutcome) -> Response {
587        match outcome {
588            AuthorizeOutcome::Redirect(resp) => resp,
589            AuthorizeOutcome::ConsentNeeded(_) => {
590                panic!("expected Redirect, got ConsentNeeded")
591            }
592        }
593    }
594
595    async fn read_body(resp: axum::http::Response<Body>) -> serde_json::Value {
596        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
597            .await
598            .unwrap();
599        serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
600    }
601
602    // Helper: create a user, session, and return (user_id, session_cookie_header)
603    async fn create_session(
604        ath: &AllowThem,
605        email: &str,
606    ) -> (allowthem_core::types::UserId, String) {
607        let email = Email::new(email.into()).unwrap();
608        let user = ath
609            .db()
610            .create_user(email, "password123", None, None)
611            .await
612            .unwrap();
613        let token = allowthem_core::generate_token();
614        let hash = allowthem_core::hash_token(&token);
615        let expires = chrono::Utc::now() + chrono::Duration::hours(24);
616        ath.db()
617            .create_session(user.id, hash, None, None, expires)
618            .await
619            .unwrap();
620        let cookie = format!("allowthem_session={}", token.as_str());
621        (user.id, cookie)
622    }
623
624    fn headers_with_cookie(cookie: &str) -> HeaderMap {
625        let mut headers = HeaderMap::new();
626        headers.insert("cookie", cookie.parse().unwrap());
627        headers
628    }
629
630    // Display error tests (steps 1-3)
631
632    #[tokio::test]
633    async fn missing_client_id_returns_400() {
634        let ath = test_ath().await;
635        let params = AuthorizeParams {
636            client_id: None,
637            redirect_uri: Some("x".into()),
638            response_type: Some("code".into()),
639            scope: Some("openid".into()),
640            state: Some("s".into()),
641            code_challenge: Some("c".into()),
642            code_challenge_method: Some("S256".into()),
643            nonce: None,
644        };
645        let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), &params).await);
646        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
647        let body = read_body(resp).await;
648        assert_eq!(body["error"], "missing client_id");
649    }
650
651    #[tokio::test]
652    async fn unknown_client_id_returns_400() {
653        let ath = test_ath().await;
654        let params = AuthorizeParams {
655            client_id: serde_json::from_value(serde_json::json!("ath_nonexistent")).ok(),
656            redirect_uri: Some("x".into()),
657            response_type: Some("code".into()),
658            scope: Some("openid".into()),
659            state: Some("s".into()),
660            code_challenge: Some("c".into()),
661            code_challenge_method: Some("S256".into()),
662            nonce: None,
663        };
664        let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), &params).await);
665        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
666        let body = read_body(resp).await;
667        assert_eq!(body["error"], "unknown client_id");
668    }
669
670    #[tokio::test]
671    async fn unregistered_redirect_uri_returns_400() {
672        let ath = test_ath().await;
673        let application = setup_application(&ath).await;
674        let params = AuthorizeParams {
675            client_id: Some(application.client_id.clone()),
676            redirect_uri: Some("https://evil.example.com/callback".into()),
677            response_type: Some("code".into()),
678            scope: Some("openid".into()),
679            state: Some("s".into()),
680            code_challenge: Some("c".into()),
681            code_challenge_method: Some("S256".into()),
682            nonce: None,
683        };
684        let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), &params).await);
685        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
686        let body = read_body(resp).await;
687        assert_eq!(body["error"], "redirect_uri not registered");
688    }
689
690    // Redirect error tests (steps 4-7)
691
692    #[tokio::test]
693    async fn missing_state_redirects_with_error() {
694        let ath = test_ath().await;
695        let application = setup_application(&ath).await;
696        let params = AuthorizeParams {
697            client_id: Some(application.client_id.clone()),
698            redirect_uri: Some("https://example.com/callback".into()),
699            response_type: Some("code".into()),
700            scope: Some("openid".into()),
701            state: None,
702            code_challenge: Some("c".into()),
703            code_challenge_method: Some("S256".into()),
704            nonce: None,
705        };
706        let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), &params).await);
707        assert_eq!(resp.status(), StatusCode::FOUND);
708        let location = resp.headers().get("location").unwrap().to_str().unwrap();
709        assert!(location.contains("error=invalid_request"));
710    }
711
712    #[tokio::test]
713    async fn bad_response_type_redirects_with_error() {
714        let ath = test_ath().await;
715        let application = setup_application(&ath).await;
716        let params = AuthorizeParams {
717            client_id: Some(application.client_id.clone()),
718            redirect_uri: Some("https://example.com/callback".into()),
719            response_type: Some("token".into()),
720            scope: Some("openid".into()),
721            state: Some("s".into()),
722            code_challenge: Some("c".into()),
723            code_challenge_method: Some("S256".into()),
724            nonce: None,
725        };
726        let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), &params).await);
727        assert_eq!(resp.status(), StatusCode::FOUND);
728        let location = resp.headers().get("location").unwrap().to_str().unwrap();
729        assert!(location.contains("error=unsupported_response_type"));
730        assert!(location.contains("state=s"));
731    }
732
733    #[tokio::test]
734    async fn invalid_scope_redirects_with_error() {
735        let ath = test_ath().await;
736        let application = setup_application(&ath).await;
737        let params = AuthorizeParams {
738            client_id: Some(application.client_id.clone()),
739            redirect_uri: Some("https://example.com/callback".into()),
740            response_type: Some("code".into()),
741            scope: Some("profile".into()),
742            state: Some("s".into()),
743            code_challenge: Some("c".into()),
744            code_challenge_method: Some("S256".into()),
745            nonce: None,
746        };
747        let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), &params).await);
748        assert_eq!(resp.status(), StatusCode::FOUND);
749        let location = resp.headers().get("location").unwrap().to_str().unwrap();
750        assert!(location.contains("error=invalid_scope"));
751    }
752
753    #[tokio::test]
754    async fn missing_pkce_redirects_with_error() {
755        let ath = test_ath().await;
756        let application = setup_application(&ath).await;
757        let params = AuthorizeParams {
758            client_id: Some(application.client_id.clone()),
759            redirect_uri: Some("https://example.com/callback".into()),
760            response_type: Some("code".into()),
761            scope: Some("openid".into()),
762            state: Some("s".into()),
763            code_challenge: None,
764            code_challenge_method: None,
765            nonce: None,
766        };
767        let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), &params).await);
768        assert_eq!(resp.status(), StatusCode::FOUND);
769        let location = resp.headers().get("location").unwrap().to_str().unwrap();
770        assert!(location.contains("error=invalid_request"));
771        assert!(location.contains("PKCE"));
772    }
773
774    // Unauthenticated user redirects to login
775
776    #[tokio::test]
777    async fn unauthenticated_redirects_to_login() {
778        let ath = test_ath().await;
779        let application = setup_application(&ath).await;
780        let params = authorize_params(&application);
781        let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), &params).await);
782        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
783        let location = resp.headers().get("location").unwrap().to_str().unwrap();
784        assert!(location.starts_with("/login?next="));
785        assert!(location.contains("oauth%2Fauthorize"));
786    }
787
788    // Authenticated user with trusted app skips consent
789
790    #[tokio::test]
791    async fn trusted_app_skips_consent_and_redirects_with_code() {
792        let ath = test_ath().await;
793        let (_, cookie) = create_session(&ath, "trusted@example.com").await;
794        let headers = headers_with_cookie(&cookie);
795
796        let (trusted_app, _) = ath
797            .db()
798            .create_application(
799                "TrustedApp".to_string(),
800                vec!["https://trusted.example.com/callback".to_string()],
801                true,
802                None,
803                None,
804                None,
805            )
806            .await
807            .unwrap();
808
809        let params = AuthorizeParams {
810            client_id: Some(trusted_app.client_id.clone()),
811            redirect_uri: Some("https://trusted.example.com/callback".into()),
812            response_type: Some("code".into()),
813            scope: Some("openid profile".into()),
814            state: Some("xyz".into()),
815            code_challenge: Some("abc123".into()),
816            code_challenge_method: Some("S256".into()),
817            nonce: None,
818        };
819
820        let resp = expect_redirect(check_authorization(&ath, &headers, &params).await);
821        assert_eq!(resp.status(), StatusCode::FOUND);
822        let location = resp.headers().get("location").unwrap().to_str().unwrap();
823        assert!(location.contains("code="));
824        assert!(location.contains("state=xyz"));
825        assert!(location.starts_with("https://trusted.example.com/callback"));
826    }
827
828    // Authenticated user without consent gets ConsentNeeded
829
830    #[tokio::test]
831    async fn untrusted_app_without_consent_returns_consent_needed() {
832        let ath = test_ath().await;
833        let (_, cookie) = create_session(&ath, "consent@example.com").await;
834        let headers = headers_with_cookie(&cookie);
835        let application = setup_application(&ath).await;
836        let params = authorize_params(&application);
837
838        let outcome = check_authorization(&ath, &headers, &params).await;
839        match outcome {
840            AuthorizeOutcome::ConsentNeeded(data) => {
841                assert_eq!(data.context.branding.application_name, "TestApp");
842                assert_eq!(data.context.scopes, vec!["openid", "profile"]);
843            }
844            AuthorizeOutcome::Redirect(_) => panic!("expected ConsentNeeded, got Redirect"),
845        }
846    }
847
848    // Inactive application returns display error
849
850    #[tokio::test]
851    async fn inactive_application_returns_400() {
852        let ath = test_ath().await;
853        let application = setup_application(&ath).await;
854
855        sqlx::query("UPDATE allowthem_applications SET is_active = 0 WHERE id = ?")
856            .bind(application.id)
857            .execute(ath.db().pool())
858            .await
859            .unwrap();
860
861        let params = AuthorizeParams {
862            client_id: Some(application.client_id.clone()),
863            redirect_uri: Some("https://example.com/callback".into()),
864            response_type: Some("code".into()),
865            scope: Some("openid".into()),
866            state: Some("s".into()),
867            code_challenge: Some("c".into()),
868            code_challenge_method: Some("S256".into()),
869            nonce: None,
870        };
871        let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), &params).await);
872        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
873        let body = read_body(resp).await;
874        assert_eq!(body["error"], "application is inactive");
875    }
876
877    // Wrong code_challenge_method redirects with error
878
879    #[tokio::test]
880    async fn wrong_pkce_method_redirects_with_error() {
881        let ath = test_ath().await;
882        let application = setup_application(&ath).await;
883        let params = AuthorizeParams {
884            client_id: Some(application.client_id.clone()),
885            redirect_uri: Some("https://example.com/callback".into()),
886            response_type: Some("code".into()),
887            scope: Some("openid".into()),
888            state: Some("s".into()),
889            code_challenge: Some("c".into()),
890            code_challenge_method: Some("plain".into()),
891            nonce: None,
892        };
893        let resp = expect_redirect(check_authorization(&ath, &HeaderMap::new(), &params).await);
894        assert_eq!(resp.status(), StatusCode::FOUND);
895        let location = resp.headers().get("location").unwrap().to_str().unwrap();
896        assert!(location.contains("error=invalid_request"));
897        assert!(location.contains("state=s"));
898    }
899
900    // Existing consent skips the consent screen
901
902    #[tokio::test]
903    async fn existing_consent_skips_consent_screen() {
904        let ath = test_ath().await;
905        let (user_id, cookie) = create_session(&ath, "existing_consent@example.com").await;
906        let headers = headers_with_cookie(&cookie);
907        let application = setup_application(&ath).await;
908
909        ath.db()
910            .upsert_consent(
911                user_id,
912                application.id,
913                &["openid".to_string(), "profile".to_string()],
914            )
915            .await
916            .unwrap();
917
918        let params = authorize_params(&application);
919        let resp = expect_redirect(check_authorization(&ath, &headers, &params).await);
920        assert_eq!(resp.status(), StatusCode::FOUND);
921        let location = resp.headers().get("location").unwrap().to_str().unwrap();
922        assert!(location.contains("code="));
923        assert!(location.contains("state=xyz"));
924    }
925
926    // POST handler tests — use a minimal router with just post(authorize_post)
927
928    fn post_app(ath: AllowThem) -> Router {
929        Router::new()
930            .route("/oauth/authorize", post(authorize_post))
931            .with_state(ath)
932    }
933
934    #[tokio::test]
935    async fn post_approve_creates_code_and_redirects_303() {
936        let ath = test_ath().await;
937        let app = post_app(ath.clone());
938        let (_, cookie) = create_session(&ath, "post_approve@example.com").await;
939        let application = setup_application(&ath).await;
940
941        let body = url::form_urlencoded::Serializer::new(String::new())
942            .append_pair("client_id", application.client_id.as_str())
943            .append_pair("redirect_uri", "https://example.com/callback")
944            .append_pair("response_type", "code")
945            .append_pair("scope", "openid profile")
946            .append_pair("state", "mystate")
947            .append_pair("code_challenge", "mychallenge")
948            .append_pair("code_challenge_method", "S256")
949            .append_pair("consent", "approve")
950            .finish();
951
952        let req = Request::builder()
953            .method("POST")
954            .uri("/oauth/authorize")
955            .header("cookie", &cookie)
956            .header("content-type", "application/x-www-form-urlencoded")
957            .body(Body::from(body))
958            .unwrap();
959        let resp = app.oneshot(req).await.unwrap();
960        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
961        let location = resp.headers().get("location").unwrap().to_str().unwrap();
962        assert!(location.starts_with("https://example.com/callback"));
963        assert!(location.contains("code="));
964        assert!(location.contains("state=mystate"));
965    }
966
967    #[tokio::test]
968    async fn post_deny_redirects_with_access_denied_303() {
969        let ath = test_ath().await;
970        let app = post_app(ath.clone());
971        let (_, cookie) = create_session(&ath, "post_deny@example.com").await;
972        let application = setup_application(&ath).await;
973
974        let body = url::form_urlencoded::Serializer::new(String::new())
975            .append_pair("client_id", application.client_id.as_str())
976            .append_pair("redirect_uri", "https://example.com/callback")
977            .append_pair("response_type", "code")
978            .append_pair("scope", "openid profile")
979            .append_pair("state", "mystate")
980            .append_pair("code_challenge", "mychallenge")
981            .append_pair("code_challenge_method", "S256")
982            .append_pair("consent", "deny")
983            .finish();
984
985        let req = Request::builder()
986            .method("POST")
987            .uri("/oauth/authorize")
988            .header("cookie", &cookie)
989            .header("content-type", "application/x-www-form-urlencoded")
990            .body(Body::from(body))
991            .unwrap();
992        let resp = app.oneshot(req).await.unwrap();
993        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
994        let location = resp.headers().get("location").unwrap().to_str().unwrap();
995        assert!(location.contains("error=access_denied"));
996        assert!(location.contains("state=mystate"));
997    }
998
999    #[tokio::test]
1000    async fn post_unauthenticated_redirects_to_login() {
1001        let ath = test_ath().await;
1002        let app = post_app(ath.clone());
1003        let application = setup_application(&ath).await;
1004
1005        let body = url::form_urlencoded::Serializer::new(String::new())
1006            .append_pair("client_id", application.client_id.as_str())
1007            .append_pair("redirect_uri", "https://example.com/callback")
1008            .append_pair("response_type", "code")
1009            .append_pair("scope", "openid")
1010            .append_pair("state", "s")
1011            .append_pair("code_challenge", "c")
1012            .append_pair("code_challenge_method", "S256")
1013            .append_pair("consent", "approve")
1014            .finish();
1015
1016        let req = Request::builder()
1017            .method("POST")
1018            .uri("/oauth/authorize")
1019            .header("content-type", "application/x-www-form-urlencoded")
1020            .body(Body::from(body))
1021            .unwrap();
1022        let resp = app.oneshot(req).await.unwrap();
1023        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1024        let location = resp.headers().get("location").unwrap().to_str().unwrap();
1025        assert!(location.starts_with("/login?next="));
1026    }
1027
1028    #[tokio::test]
1029    async fn post_with_invalid_client_id_returns_400() {
1030        let ath = test_ath().await;
1031        let app = post_app(ath.clone());
1032        let (_, cookie) = create_session(&ath, "post_revalidate@example.com").await;
1033
1034        let body = url::form_urlencoded::Serializer::new(String::new())
1035            .append_pair("client_id", "ath_nonexistent")
1036            .append_pair("redirect_uri", "https://example.com/callback")
1037            .append_pair("response_type", "code")
1038            .append_pair("scope", "openid")
1039            .append_pair("state", "s")
1040            .append_pair("code_challenge", "c")
1041            .append_pair("code_challenge_method", "S256")
1042            .append_pair("consent", "approve")
1043            .finish();
1044
1045        let req = Request::builder()
1046            .method("POST")
1047            .uri("/oauth/authorize")
1048            .header("cookie", &cookie)
1049            .header("content-type", "application/x-www-form-urlencoded")
1050            .body(Body::from(body))
1051            .unwrap();
1052        let resp = app.oneshot(req).await.unwrap();
1053        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1054        let body = read_body(resp).await;
1055        assert_eq!(body["error"], "unknown client_id");
1056    }
1057}