Skip to main content

headless_lms_server/controllers/main_frontend/oauth/
authorize.rs

1use crate::domain::oauth::authorize_query::{AuthorizeParams, AuthorizeQuery};
2use crate::domain::oauth::client_metadata_document::{self, is_metadata_document_client_id};
3use crate::domain::oauth::helpers::{oauth_error, oauth_invalid_request};
4use crate::domain::oauth::oauth_validated::OAuthValidated;
5use crate::domain::oauth::pkce::parse_authorize_pkce;
6use crate::domain::oauth::redirects::{
7    build_authorize_qs, build_consent_redirect, build_login_redirect, redirect_uri_matches,
8    redirect_with_code,
9};
10use crate::domain::oauth::resource_indicator::ResourceIndicator;
11use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig};
12use crate::prelude::*;
13use actix_web::web;
14use chrono::{Duration, Utc};
15use domain::error::OAuthErrorCode;
16use itertools::Itertools;
17use models::{
18    library::oauth::{generate_access_token, mcp::McpScope, token_digest_sha256},
19    oauth_auth_code::{NewAuthCodeParams, OAuthAuthCode},
20    oauth_client::OAuthClient,
21    oauth_user_client_scopes::OAuthUserClientScopes,
22};
23use sqlx::PgPool;
24use std::collections::HashSet;
25use utoipa::OpenApi;
26
27#[derive(OpenApi)]
28#[openapi(paths(authorize_get_doc, authorize_post_doc))]
29#[allow(dead_code)]
30pub(crate) struct MainFrontendOauthAuthorizeApiDoc;
31
32#[derive(Debug, Clone, Copy, Default)]
33struct PromptFlags {
34    none: bool,
35    consent: bool,
36    login: bool,
37    select_account: bool,
38}
39
40fn parse_prompt(prompt: Option<&str>) -> Result<PromptFlags, &'static str> {
41    let mut f = PromptFlags::default();
42    let Some(p) = prompt else { return Ok(f) };
43
44    for v in p.split_whitespace() {
45        match v {
46            "none" => f.none = true,
47            "consent" => f.consent = true,
48            "login" => f.login = true,
49            "select_account" => f.select_account = true,
50            _ => return Err("unsupported prompt value"),
51        }
52    }
53
54    if f.none && (f.consent || f.login || f.select_account) {
55        return Err("prompt=none cannot be combined with other values");
56    }
57
58    Ok(f)
59}
60
61/// Enforces RFC 8707 resource indicators for the MCP server: its scopes need its URL as `resource`,
62/// and it takes tokens only for metadata document clients.
63fn check_resource(
64    client: &OAuthClient,
65    query: &AuthorizeParams,
66    app_conf: &ApplicationConfiguration,
67) -> Result<(), ControllerError> {
68    let redirect = Some(query.redirect_uri.as_str());
69    let state = query.state.as_deref();
70    let Some(resource) = &query.resource else {
71        let requests_mcp_scope = query
72            .scope
73            .split_whitespace()
74            .any(|scope| McpScope::parse(scope).is_some());
75        if requests_mcp_scope {
76            return Err(oauth_invalid_request(
77                "resource is required for MCP scopes",
78                redirect,
79                state,
80            ));
81        }
82        return Ok(());
83    };
84    if resource.as_str() != app_conf.mcp_resource_url() {
85        return Err(oauth_error(
86            OAuthErrorCode::InvalidTarget,
87            "unknown resource",
88            redirect,
89            state,
90        ));
91    }
92    if !client.is_metadata_document_client() {
93        return Err(oauth_error(
94            OAuthErrorCode::InvalidTarget,
95            "the MCP server accepts only clients registered with a client ID metadata document",
96            redirect,
97            state,
98        ));
99    }
100    Ok(())
101}
102
103/// The answer to an authorization request without a signed-in user: `login_required` for
104/// `prompt=none`, else the login page. The error is redirected to `error_redirect`, which must be
105/// a verified redirect URI of the client.
106fn anonymous_response(
107    query: &AuthorizeParams,
108    is_prompt_none: bool,
109    error_redirect: Option<&str>,
110) -> Result<String, ControllerError> {
111    if is_prompt_none {
112        return Err(oauth_error(
113            OAuthErrorCode::LoginRequired,
114            "end-user is not logged in",
115            error_redirect,
116            query.state.as_deref(),
117        ));
118    }
119    Ok(build_login_redirect(query))
120}
121
122/// Handles the `/authorize` endpoint for OAuth 2.0 and OpenID Connect with PKCE support.
123///
124/// This endpoint:
125/// - Validates the incoming authorization request parameters.
126/// - Verifies the client, redirect URI, and requested scopes.
127/// - Enforces PKCE requirements (`code_challenge` and `code_challenge_method`) for public clients or clients configured with `require_pkce = true`.
128/// - If the user is logged in and has already granted the requested scopes, issues an authorization code and redirects back to the client.
129/// - If the user is logged in but missing consent for some scopes, redirects them to the consent screen.
130/// - If the user is not logged in, redirects them to the login page.
131///
132/// Note: DPoP (Demonstrating Proof-of-Possession) is not used at this endpoint. DPoP binding
133/// occurs at the `/token` endpoint when exchanging authorization codes for access tokens.
134///
135/// Follows:
136/// - [RFC 6749 Section 3.1](https://datatracker.ietf.org/doc/html/rfc6749#section-3.1) — Authorization Endpoint
137///   - Supports both GET (query parameters) and POST (form-encoded body) methods
138/// - [RFC 7636 (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636) — Proof Key for Code Exchange
139/// - [OpenID Connect Core 1.0 Section 3](https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint)
140///
141/// # Examples
142/// ```http
143/// 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
144/// ```
145///
146/// ```http
147/// POST /api/v0/main-frontend/oauth/authorize HTTP/1.1
148/// Content-Type: application/x-www-form-urlencoded
149///
150/// 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
151/// ```
152///
153/// Successful redirect:
154/// ```http
155/// HTTP/1.1 302 Found
156/// Location: http://localhost?code=SplxlOBeZQQYbYS6WxSbIA&state=random123
157/// ```
158pub async fn authorize(
159    pool: web::Data<PgPool>,
160    OAuthValidated(query): OAuthValidated<AuthorizeQuery>,
161    user: Option<AuthUser>,
162    app_conf: web::Data<headless_lms_base::config::ApplicationConfiguration>,
163) -> ControllerResult<HttpResponse> {
164    let issuer = app_conf.oauth_issuer();
165    respond_to_authorize(pool, query, user, app_conf)
166        .await
167        .map_err(|err| err.with_oauth_issuer(issuer))
168}
169
170async fn respond_to_authorize(
171    pool: web::Data<PgPool>,
172    query: AuthorizeParams,
173    user: Option<AuthUser>,
174    app_conf: web::Data<headless_lms_base::config::ApplicationConfiguration>,
175) -> ControllerResult<HttpResponse> {
176    let server_token = skip_authorize();
177    // Resolved before taking a connection: it may fetch the document from the client's host.
178    let resolved_client = match &user {
179        Some(user) if is_metadata_document_client_id(&query.client_id) => {
180            Some(client_metadata_document::resolve_client(&pool, &query.client_id, user.id).await?)
181        }
182        _ => None,
183    };
184    let mut conn = pool.acquire().await?;
185
186    let client = if let Some(client) = resolved_client {
187        client
188    } else if is_metadata_document_client_id(&query.client_id) {
189        // Fetching for anonymous requests would let anyone make the server fetch URLs and store
190        // clients; an unknown client is checked after login instead.
191        match client_metadata_document::find_cached_client(&mut conn, &query.client_id).await? {
192            Some(client) => client,
193            None => {
194                let is_prompt_none =
195                    parse_prompt(query.prompt.as_deref()).is_ok_and(|prompt| prompt.none);
196                let location = anonymous_response(&query, is_prompt_none, None)?;
197                return server_token.authorized_ok(
198                    HttpResponse::Found()
199                        .append_header(("Location", location))
200                        .finish(),
201                );
202            }
203        }
204    } else {
205        OAuthClient::find_by_client_id(&mut conn, &query.client_id)
206            .await
207            .map_err(|e| {
208                tracing::error!(err = %e, "OAuth authorize: client lookup failed");
209                oauth_invalid_request(
210                    "invalid client_id",
211                    None, // Cannot verify redirect_uri without valid client_id (security: prevent open redirect)
212                    query.state.as_deref(),
213                )
214            })?
215    };
216
217    // Add non-secret fields to the span for observability
218    tracing::Span::current().record("client_id", &query.client_id);
219    tracing::Span::current().record("response_type", &query.response_type);
220
221    if !redirect_uri_matches(&client, &query.redirect_uri) {
222        return Err(oauth_invalid_request(
223            "redirect_uri does not match client",
224            None, // Never redirect to an invalid redirect_uri (security)
225            query.state.as_deref(),
226        ));
227    }
228
229    check_resource(&client, &query, &app_conf)?;
230
231    if query.request.is_some() {
232        return Err(oauth_error(
233            OAuthErrorCode::RequestNotSupported,
234            "request object is not supported",
235            Some(&query.redirect_uri),
236            query.state.as_deref(),
237        ));
238    }
239
240    let prompt = parse_prompt(query.prompt.as_deref()).map_err(|msg| {
241        oauth_invalid_request(msg, Some(&query.redirect_uri), query.state.as_deref())
242    })?;
243
244    if prompt.login {
245        return Err(oauth_invalid_request(
246            "prompt=login is not supported",
247            Some(&query.redirect_uri),
248            query.state.as_deref(),
249        ));
250    }
251
252    if prompt.select_account {
253        return Err(oauth_invalid_request(
254            "prompt=select_account is not supported",
255            Some(&query.redirect_uri),
256            query.state.as_deref(),
257        ));
258    }
259
260    let parsed_pkce_method = parse_authorize_pkce(
261        &client,
262        query.code_challenge.as_deref(),
263        query.code_challenge_method.as_deref(),
264        &query.redirect_uri,
265        query.state.as_deref(),
266    )?;
267
268    let redirect_url = match user {
269        Some(user) => {
270            let granted_scopes: Vec<String> =
271                OAuthUserClientScopes::find_scopes(&mut conn, user.id, client.id).await?;
272
273            let requested: HashSet<&str> = query.scope.split_whitespace().collect();
274            let granted: HashSet<&str> = granted_scopes.iter().map(|s| s.as_str()).collect();
275            let missing: Vec<&str> = requested.difference(&granted).copied().collect();
276            if prompt.none && !missing.is_empty() {
277                return Err(oauth_error(
278                    OAuthErrorCode::ConsentRequired,
279                    "end-user consent is required",
280                    Some(&query.redirect_uri),
281                    query.state.as_deref(),
282                ));
283            }
284
285            if prompt.consent || !missing.is_empty() {
286                let return_to = format!(
287                    "/api/v0/main-frontend/oauth/authorize?{}",
288                    build_authorize_qs(&query)
289                );
290                build_consent_redirect(&query, &return_to)
291            } else {
292                let code = generate_access_token();
293                let expires_at = Utc::now() + Duration::minutes(10);
294                let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
295                let code_digest = token_digest_sha256(&code, token_hmac_key);
296
297                let new_auth_code_params = NewAuthCodeParams {
298                    digest: &code_digest,
299                    user_id: user.id,
300                    client_id: client.id,
301                    redirect_uri: &query.redirect_uri,
302                    scopes: &query
303                        .scope
304                        .split_whitespace()
305                        .map(|s| s.to_string())
306                        .collect_vec(),
307                    nonce: query.nonce.as_deref(),
308                    code_challenge: query.code_challenge.as_deref(),
309                    code_challenge_method: parsed_pkce_method,
310                    dpop_jkt: None, // DPoP binding occurs at /token endpoint, not at /authorize
311                    expires_at,
312                    metadata: serde_json::Map::new(),
313                    resource: query.resource.as_ref().map(ResourceIndicator::as_str),
314                };
315
316                OAuthAuthCode::insert(&mut conn, new_auth_code_params).await?;
317                redirect_with_code(
318                    &query.redirect_uri,
319                    &code,
320                    query.state.as_deref(),
321                    &app_conf.oauth_issuer(),
322                )
323            }
324        }
325        None => anonymous_response(&query, prompt.none, Some(&query.redirect_uri))?,
326    };
327
328    server_token.authorized_ok(
329        HttpResponse::Found()
330            .append_header(("Location", redirect_url))
331            .finish(),
332    )
333}
334
335#[utoipa::path(
336    get,
337    path = "/authorize",
338    operation_id = "authorizeOauthGet",
339    tag = "oauth",
340    params(
341        ("response_type" = Option<String>, Query, description = "OAuth response type"),
342        ("client_id" = Option<String>, Query, description = "OAuth client id"),
343        ("redirect_uri" = Option<String>, Query, description = "Redirect URI"),
344        ("scope" = Option<String>, Query, description = "Requested scopes"),
345        ("state" = Option<String>, Query, description = "OAuth state"),
346        ("nonce" = Option<String>, Query, description = "OpenID Connect nonce"),
347        ("code_challenge" = Option<String>, Query, description = "PKCE code challenge"),
348        ("code_challenge_method" = Option<String>, Query, description = "PKCE code challenge method"),
349        ("prompt" = Option<String>, Query, description = "Prompt behavior"),
350        ("request" = Option<String>, Query, description = "Unsupported request object")
351    ),
352    responses(
353        (status = 302, description = "Redirect to login, consent, or client redirect URI")
354    )
355)]
356#[allow(dead_code)]
357pub(crate) fn authorize_get_doc() {}
358
359#[utoipa::path(
360    post,
361    path = "/authorize",
362    operation_id = "authorizeOauthPost",
363    tag = "oauth",
364    request_body(
365        content = serde_json::Value,
366        content_type = "application/x-www-form-urlencoded"
367    ),
368    responses(
369        (status = 302, description = "Redirect to login, consent, or client redirect URI")
370    )
371)]
372#[allow(dead_code)]
373pub(crate) fn authorize_post_doc() {}
374
375pub fn _add_routes(cfg: &mut web::ServiceConfig) {
376    cfg.service(
377        web::resource("/authorize")
378            .wrap(RateLimit::new(RateLimitConfig {
379                per_minute: Some(100),
380                per_hour: Some(500),
381                per_day: Some(2000),
382                per_month: None,
383                ..Default::default()
384            }))
385            .route(web::get().to(authorize))
386            .route(web::post().to(authorize)),
387    );
388}