headless_lms_server/controllers/main_frontend/oauth/
consent.rs

1use crate::domain::oauth::consent_deny_query::ConsentDenyQuery;
2use crate::domain::oauth::consent_query::ConsentQuery;
3use crate::domain::oauth::consent_response::ConsentResponse;
4use crate::domain::oauth::helpers::oauth_invalid_request;
5use crate::prelude::*;
6use actix_web::{Error, HttpResponse, web};
7use models::{
8    error::ModelErrorType, oauth_client::OAuthClient,
9    oauth_user_client_scopes::OAuthUserClientScopes,
10};
11use sqlx::PgPool;
12use url::{Url, form_urlencoded};
13use utoipa::OpenApi;
14
15#[derive(OpenApi)]
16#[openapi(paths(approve_consent, deny_consent))]
17#[allow(dead_code)]
18pub(crate) struct MainFrontendOauthConsentApiDoc;
19
20/// Handles `/consent` approval after the user agrees to grant requested scopes.
21///
22/// This endpoint:
23/// - Validates the redirect URI and requested scopes against the registered client.
24/// - Records granted scopes for the user-client pair.
25/// - Redirects back to `/authorize` to continue the OAuth flow.
26///
27/// # Example
28/// ```http
29/// GET /api/v0/main-frontend/oauth/consent?client_id=test-client-id&redirect_uri=http://localhost&scopes=openid%20profile&state=random123&nonce=secure_nonce_abc HTTP/1.1
30/// Cookie: session=abc123
31///
32/// ```
33///
34/// Redirect back to `/authorize`:
35/// ```http
36/// HTTP/1.1 302 Found
37/// Location: /api/v0/main-frontend/oauth/authorize?client_id=...
38/// ```
39#[instrument(skip(pool))]
40#[utoipa::path(
41    post,
42    path = "/consent",
43    operation_id = "approveOauthConsent",
44    tag = "oauth",
45    request_body = ConsentQuery,
46    responses(
47        (status = 200, description = "Consent approval response", body = ConsentResponse)
48    )
49)]
50pub async fn approve_consent(
51    pool: web::Data<PgPool>,
52    form: web::Json<ConsentQuery>,
53    user: AuthUser,
54) -> ControllerResult<HttpResponse> {
55    let mut conn = pool.acquire().await?;
56    let token = skip_authorize();
57
58    let client = OAuthClient::find_by_client_id(&mut conn, &form.client_id).await?;
59    if !client.redirect_uris.contains(&form.redirect_uri) {
60        return Err(oauth_invalid_request(
61            "redirect_uri does not match client",
62            None, // Never redirect to an invalid redirect_uri (security)
63            Some(&form.state),
64        ));
65    }
66
67    // Validate requested scopes against client.allowed scopes
68    let requested_scopes: Vec<String> = form
69        .scope
70        .split_whitespace()
71        .map(|s| s.to_string())
72        .collect();
73
74    let allowed_scopes = &client.scopes;
75
76    for scope in &requested_scopes {
77        if !allowed_scopes.contains(scope) {
78            return Err(oauth_invalid_request(
79                "invalid scope",
80                None,
81                Some(&form.state),
82            ));
83        }
84    }
85
86    OAuthUserClientScopes::insert(&mut conn, user.id, client.id, &requested_scopes).await?;
87
88    // Redirect to /authorize (the OAuth authorize endpoint typically remains a GET)
89    let mut query_string = String::new();
90    {
91        let mut query_builder = form_urlencoded::Serializer::new(&mut query_string);
92        query_builder
93            .append_pair("client_id", &form.client_id)
94            .append_pair("redirect_uri", &form.redirect_uri)
95            .append_pair("scope", &form.scope)
96            .append_pair("state", &form.state)
97            .append_pair("nonce", &form.nonce)
98            .append_pair("response_type", &form.response_type);
99
100        // Preserve PKCE parameters if present
101        if let Some(code_challenge) = &form.code_challenge {
102            query_builder.append_pair("code_challenge", code_challenge);
103        }
104        if let Some(code_challenge_method) = &form.code_challenge_method {
105            query_builder.append_pair("code_challenge_method", code_challenge_method);
106        }
107    }
108    let query = query_string;
109
110    // Relative Location: browser resolves against current origin
111    let location = format!("/api/v0/main-frontend/oauth/authorize?{}", query);
112
113    token.authorized_ok(HttpResponse::Ok().json(ConsentResponse {
114        redirect_uri: location,
115    }))
116}
117
118/// Handles `/consent/deny` when the user refuses to grant scopes.
119///
120/// This endpoint:
121/// - Redirects back to the client with `error=access_denied`.
122///
123/// # Example
124/// ```http
125/// GET /api/v0/main-frontend/oauth/consent/deny?redirect_uri=http://localhost&state=random123 HTTP/1.1
126///
127/// ```
128///
129/// Response:
130/// ```http
131/// HTTP/1.1 302 Found
132/// Location: http://localhost?error=access_denied&state=random123
133/// ```
134#[instrument]
135#[utoipa::path(
136    post,
137    path = "/consent/deny",
138    operation_id = "denyOauthConsent",
139    tag = "oauth",
140    request_body = ConsentDenyQuery,
141    responses(
142        (status = 200, description = "Consent denial response", body = ConsentResponse)
143    )
144)]
145pub async fn deny_consent(
146    pool: web::Data<PgPool>,
147    form: web::Json<ConsentDenyQuery>,
148) -> Result<HttpResponse, Error> {
149    let mut conn = pool.acquire().await.map_err(|e| {
150        tracing::error!(err = %e, "OAuth consent/deny: pool acquire failed");
151        actix_web::error::ErrorInternalServerError(e)
152    })?;
153
154    let client_result = OAuthClient::find_by_client_id(&mut conn, &form.client_id).await;
155    let client = match client_result {
156        Ok(c) => c,
157        Err(err) => {
158            return Err(match err.error_type() {
159                ModelErrorType::RecordNotFound | ModelErrorType::NotFound => {
160                    actix_web::error::ErrorNotFound("client not found")
161                }
162                _ => {
163                    tracing::error!(err = %err, "OAuth consent/deny: client lookup failed");
164                    actix_web::error::ErrorInternalServerError(err)
165                }
166            });
167        }
168    };
169
170    if !client.redirect_uris.contains(&form.redirect_uri) {
171        return Err(actix_web::error::ErrorBadRequest("invalid redirect URI"));
172    }
173
174    let mut url = Url::parse(&form.redirect_uri)
175        .map_err(|_| actix_web::error::ErrorBadRequest("invalid redirect URI"))?;
176
177    {
178        let mut qp = url.query_pairs_mut();
179        qp.append_pair("error", "access_denied");
180        if !form.state.is_empty() {
181            qp.append_pair("state", &form.state);
182        }
183    }
184
185    Ok(HttpResponse::Ok().json(ConsentResponse {
186        redirect_uri: url.to_string(),
187    }))
188}
189
190pub fn _add_routes(cfg: &mut web::ServiceConfig) {
191    cfg.route("/consent", web::post().to(approve_consent))
192        .route("/consent/deny", web::post().to(deny_consent));
193}