Skip to main content

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, split_and_validate_scopes};
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    let requested_scopes = split_and_validate_scopes(&form.scope, &client.scopes)
68        .map_err(|_| oauth_invalid_request("invalid scope", None, Some(&form.state)))?;
69
70    OAuthUserClientScopes::insert(&mut conn, user.id, client.id, &requested_scopes).await?;
71
72    // Redirect to /authorize (the OAuth authorize endpoint typically remains a GET)
73    let mut query_string = String::new();
74    {
75        let mut query_builder = form_urlencoded::Serializer::new(&mut query_string);
76        query_builder
77            .append_pair("client_id", &form.client_id)
78            .append_pair("redirect_uri", &form.redirect_uri)
79            .append_pair("scope", &form.scope)
80            .append_pair("state", &form.state)
81            .append_pair("nonce", &form.nonce)
82            .append_pair("response_type", &form.response_type);
83
84        // Preserve PKCE parameters if present
85        if let Some(code_challenge) = &form.code_challenge {
86            query_builder.append_pair("code_challenge", code_challenge);
87        }
88        if let Some(code_challenge_method) = &form.code_challenge_method {
89            query_builder.append_pair("code_challenge_method", code_challenge_method);
90        }
91    }
92    let query = query_string;
93
94    // Relative Location: browser resolves against current origin
95    let location = format!("/api/v0/main-frontend/oauth/authorize?{}", query);
96
97    token.authorized_ok(HttpResponse::Ok().json(ConsentResponse {
98        redirect_uri: location,
99    }))
100}
101
102/// Handles `/consent/deny` when the user refuses to grant scopes.
103///
104/// This endpoint:
105/// - Redirects back to the client with `error=access_denied`.
106///
107/// # Example
108/// ```http
109/// GET /api/v0/main-frontend/oauth/consent/deny?redirect_uri=http://localhost&state=random123 HTTP/1.1
110///
111/// ```
112///
113/// Response:
114/// ```http
115/// HTTP/1.1 302 Found
116/// Location: http://localhost?error=access_denied&state=random123
117/// ```
118#[instrument]
119#[utoipa::path(
120    post,
121    path = "/consent/deny",
122    operation_id = "denyOauthConsent",
123    tag = "oauth",
124    request_body = ConsentDenyQuery,
125    responses(
126        (status = 200, description = "Consent denial response", body = ConsentResponse)
127    )
128)]
129pub async fn deny_consent(
130    pool: web::Data<PgPool>,
131    form: web::Json<ConsentDenyQuery>,
132) -> Result<HttpResponse, Error> {
133    let mut conn = pool.acquire().await.map_err(|e| {
134        tracing::error!(err = %e, "OAuth consent/deny: pool acquire failed");
135        actix_web::error::ErrorInternalServerError(e)
136    })?;
137
138    let client_result = OAuthClient::find_by_client_id(&mut conn, &form.client_id).await;
139    let client = match client_result {
140        Ok(c) => c,
141        Err(err) => {
142            return Err(match err.error_type() {
143                ModelErrorType::RecordNotFound | ModelErrorType::NotFound => {
144                    actix_web::error::ErrorNotFound("client not found")
145                }
146                _ => {
147                    tracing::error!(err = %err, "OAuth consent/deny: client lookup failed");
148                    actix_web::error::ErrorInternalServerError(err)
149                }
150            });
151        }
152    };
153
154    if !client.redirect_uris.contains(&form.redirect_uri) {
155        return Err(actix_web::error::ErrorBadRequest("invalid redirect URI"));
156    }
157
158    let mut url = Url::parse(&form.redirect_uri)
159        .map_err(|_| actix_web::error::ErrorBadRequest("invalid redirect URI"))?;
160
161    {
162        let mut qp = url.query_pairs_mut();
163        qp.append_pair("error", "access_denied");
164        if !form.state.is_empty() {
165            qp.append_pair("state", &form.state);
166        }
167    }
168
169    Ok(HttpResponse::Ok().json(ConsentResponse {
170        redirect_uri: url.to_string(),
171    }))
172}
173
174pub fn _add_routes(cfg: &mut web::ServiceConfig) {
175    cfg.route("/consent", web::post().to(approve_consent))
176        .route("/consent/deny", web::post().to(deny_consent));
177}