headless_lms_server/controllers/main_frontend/oauth/
authorize.rs

1use crate::domain::oauth::authorize_query::AuthorizeQuery;
2use crate::domain::oauth::helpers::{oauth_error, oauth_invalid_request};
3use crate::domain::oauth::oauth_validated::OAuthValidated;
4use crate::domain::oauth::pkce::parse_authorize_pkce;
5use crate::domain::oauth::redirects::{
6    build_authorize_qs, build_consent_redirect, build_login_redirect, redirect_with_code,
7};
8use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig};
9use crate::prelude::*;
10use actix_web::web;
11use chrono::{Duration, Utc};
12use itertools::Itertools;
13use models::{
14    library::oauth::{generate_access_token, token_digest_sha256},
15    oauth_auth_code::{NewAuthCodeParams, OAuthAuthCode},
16    oauth_client::OAuthClient,
17    oauth_user_client_scopes::OAuthUserClientScopes,
18};
19use sqlx::PgPool;
20use std::collections::HashSet;
21use utoipa::OpenApi;
22
23#[derive(OpenApi)]
24#[openapi(paths(authorize_get_doc, authorize_post_doc))]
25#[allow(dead_code)]
26pub(crate) struct MainFrontendOauthAuthorizeApiDoc;
27
28#[derive(Debug, Clone, Copy, Default)]
29struct PromptFlags {
30    none: bool,
31    consent: bool,
32    login: bool,
33    select_account: bool,
34}
35
36fn parse_prompt(prompt: Option<&str>) -> Result<PromptFlags, &'static str> {
37    let mut f = PromptFlags::default();
38    let Some(p) = prompt else { return Ok(f) };
39
40    for v in p.split_whitespace() {
41        match v {
42            "none" => f.none = true,
43            "consent" => f.consent = true,
44            "login" => f.login = true,
45            "select_account" => f.select_account = true,
46            _ => return Err("unsupported prompt value"),
47        }
48    }
49
50    if f.none && (f.consent || f.login || f.select_account) {
51        return Err("prompt=none cannot be combined with other values");
52    }
53
54    Ok(f)
55}
56
57/// Handles the `/authorize` endpoint for OAuth 2.0 and OpenID Connect with PKCE support.
58///
59/// This endpoint:
60/// - Validates the incoming authorization request parameters.
61/// - Verifies the client, redirect URI, and requested scopes.
62/// - Enforces PKCE requirements (`code_challenge` and `code_challenge_method`) for public clients or clients configured with `require_pkce = true`.
63/// - If the user is logged in and has already granted the requested scopes, issues an authorization code and redirects back to the client.
64/// - If the user is logged in but missing consent for some scopes, redirects them to the consent screen.
65/// - If the user is not logged in, redirects them to the login page.
66///
67/// Note: DPoP (Demonstrating Proof-of-Possession) is not used at this endpoint. DPoP binding
68/// occurs at the `/token` endpoint when exchanging authorization codes for access tokens.
69///
70/// Follows:
71/// - [RFC 6749 Section 3.1](https://datatracker.ietf.org/doc/html/rfc6749#section-3.1) — Authorization Endpoint
72///   - Supports both GET (query parameters) and POST (form-encoded body) methods
73/// - [RFC 7636 (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636) — Proof Key for Code Exchange
74/// - [OpenID Connect Core 1.0 Section 3](https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint)
75///
76/// # Examples
77/// ```http
78/// GET /api/v0/main-frontend/oauth/authorize?response_type=code&client_id=test-client-id&redirect_uri=http://localhost&scope=openid%20profile%20email&state=random123&nonce=secure_nonce_abc&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256 HTTP/1.1
79/// ```
80///
81/// ```http
82/// POST /api/v0/main-frontend/oauth/authorize HTTP/1.1
83/// Content-Type: application/x-www-form-urlencoded
84///
85/// response_type=code&client_id=test-client-id&redirect_uri=http://localhost&scope=openid%20profile%20email&state=random123&nonce=secure_nonce_abc&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256
86/// ```
87///
88/// Successful redirect:
89/// ```http
90/// HTTP/1.1 302 Found
91/// Location: http://localhost?code=SplxlOBeZQQYbYS6WxSbIA&state=random123
92/// ```
93pub async fn authorize(
94    pool: web::Data<PgPool>,
95    OAuthValidated(query): OAuthValidated<AuthorizeQuery>,
96    user: Option<AuthUser>,
97    app_conf: web::Data<headless_lms_base::config::ApplicationConfiguration>,
98) -> ControllerResult<HttpResponse> {
99    let mut conn = pool.acquire().await?;
100    let server_token = skip_authorize();
101
102    let client = OAuthClient::find_by_client_id(&mut conn, &query.client_id)
103        .await
104        .map_err(|e| {
105            tracing::error!(err = %e, "OAuth authorize: client lookup failed");
106            oauth_invalid_request(
107                "invalid client_id",
108                None, // Cannot verify redirect_uri without valid client_id (security: prevent open redirect)
109                query.state.as_deref(),
110            )
111        })?;
112
113    // Add non-secret fields to the span for observability
114    tracing::Span::current().record("client_id", &query.client_id);
115    tracing::Span::current().record("response_type", &query.response_type);
116
117    if !client.redirect_uris.contains(&query.redirect_uri) {
118        return Err(oauth_invalid_request(
119            "redirect_uri does not match client",
120            None, // Never redirect to an invalid redirect_uri (security)
121            query.state.as_deref(),
122        ));
123    }
124
125    if query.request.is_some() {
126        return Err(oauth_error(
127            "request_not_supported",
128            "request object is not supported",
129            Some(&query.redirect_uri),
130            query.state.as_deref(),
131        ));
132    }
133
134    let prompt = parse_prompt(query.prompt.as_deref()).map_err(|msg| {
135        oauth_invalid_request(msg, Some(&query.redirect_uri), query.state.as_deref())
136    })?;
137
138    if prompt.login {
139        return Err(oauth_error(
140            "inalid_request",
141            "prompt=login is not supported",
142            Some(&query.redirect_uri),
143            query.state.as_deref(),
144        ));
145    }
146
147    if prompt.select_account {
148        return Err(oauth_error(
149            "inalid_request",
150            "prompt=select_account is not supported",
151            Some(&query.redirect_uri),
152            query.state.as_deref(),
153        ));
154    }
155
156    let parsed_pkce_method = parse_authorize_pkce(
157        &client,
158        query.code_challenge.as_deref(),
159        query.code_challenge_method.as_deref(),
160        &query.redirect_uri,
161        query.state.as_deref(),
162    )?;
163
164    let redirect_url = match user {
165        Some(user) => {
166            let granted_scopes: Vec<String> =
167                OAuthUserClientScopes::find_scopes(&mut conn, user.id, client.id).await?;
168
169            let requested: HashSet<&str> = query.scope.split_whitespace().collect();
170            let granted: HashSet<&str> = granted_scopes.iter().map(|s| s.as_str()).collect();
171            let missing: Vec<&str> = requested.difference(&granted).copied().collect();
172            if prompt.none && !missing.is_empty() {
173                return Err(oauth_error(
174                    "consent_required",
175                    "end-user consent is required",
176                    Some(&query.redirect_uri),
177                    query.state.as_deref(),
178                ));
179            }
180
181            if prompt.consent || !missing.is_empty() {
182                let return_to = format!(
183                    "/api/v0/main-frontend/oauth/authorize?{}",
184                    build_authorize_qs(&query)
185                );
186                build_consent_redirect(&query, &return_to)
187            } else {
188                let code = generate_access_token();
189                let expires_at = Utc::now() + Duration::minutes(10);
190                let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
191                let code_digest = token_digest_sha256(&code, token_hmac_key);
192
193                let new_auth_code_params = NewAuthCodeParams {
194                    digest: &code_digest,
195                    user_id: user.id,
196                    client_id: client.id,
197                    redirect_uri: &query.redirect_uri,
198                    scopes: &query
199                        .scope
200                        .split_whitespace()
201                        .map(|s| s.to_string())
202                        .collect_vec(),
203                    nonce: query.nonce.as_deref(),
204                    code_challenge: query.code_challenge.as_deref(),
205                    code_challenge_method: parsed_pkce_method,
206                    dpop_jkt: None, // DPoP binding occurs at /token endpoint, not at /authorize
207                    expires_at,
208                    metadata: serde_json::Map::new(),
209                };
210
211                OAuthAuthCode::insert(&mut conn, new_auth_code_params).await?;
212                redirect_with_code(&query.redirect_uri, &code, query.state.as_deref())
213            }
214        }
215        None => {
216            if prompt.none {
217                return Err(oauth_error(
218                    "login_required",
219                    "end-user is not logged in",
220                    Some(&query.redirect_uri),
221                    query.state.as_deref(),
222                ));
223            }
224            build_login_redirect(&query)
225        }
226    };
227
228    server_token.authorized_ok(
229        HttpResponse::Found()
230            .append_header(("Location", redirect_url))
231            .finish(),
232    )
233}
234
235#[utoipa::path(
236    get,
237    path = "/authorize",
238    operation_id = "authorizeOauthGet",
239    tag = "oauth",
240    params(
241        ("response_type" = Option<String>, Query, description = "OAuth response type"),
242        ("client_id" = Option<String>, Query, description = "OAuth client id"),
243        ("redirect_uri" = Option<String>, Query, description = "Redirect URI"),
244        ("scope" = Option<String>, Query, description = "Requested scopes"),
245        ("state" = Option<String>, Query, description = "OAuth state"),
246        ("nonce" = Option<String>, Query, description = "OpenID Connect nonce"),
247        ("code_challenge" = Option<String>, Query, description = "PKCE code challenge"),
248        ("code_challenge_method" = Option<String>, Query, description = "PKCE code challenge method"),
249        ("prompt" = Option<String>, Query, description = "Prompt behavior"),
250        ("request" = Option<String>, Query, description = "Unsupported request object")
251    ),
252    responses(
253        (status = 302, description = "Redirect to login, consent, or client redirect URI")
254    )
255)]
256#[allow(dead_code)]
257pub(crate) fn authorize_get_doc() {}
258
259#[utoipa::path(
260    post,
261    path = "/authorize",
262    operation_id = "authorizeOauthPost",
263    tag = "oauth",
264    request_body(
265        content = serde_json::Value,
266        content_type = "application/x-www-form-urlencoded"
267    ),
268    responses(
269        (status = 302, description = "Redirect to login, consent, or client redirect URI")
270    )
271)]
272#[allow(dead_code)]
273pub(crate) fn authorize_post_doc() {}
274
275pub fn _add_routes(cfg: &mut web::ServiceConfig) {
276    cfg.service(
277        web::resource("/authorize")
278            .wrap(RateLimit::new(RateLimitConfig {
279                per_minute: Some(100),
280                per_hour: Some(500),
281                per_day: Some(2000),
282                per_month: None,
283                ..Default::default()
284            }))
285            .route(web::get().to(authorize))
286            .route(web::post().to(authorize)),
287    );
288}