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};
13
14/// Handles `/consent` approval after the user agrees to grant requested scopes.
15///
16/// This endpoint:
17/// - Validates the redirect URI and requested scopes against the registered client.
18/// - Records granted scopes for the user-client pair.
19/// - Redirects back to `/authorize` to continue the OAuth flow.
20///
21/// # Example
22/// ```http
23/// 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
24/// Cookie: session=abc123
25///
26/// ```
27///
28/// Redirect back to `/authorize`:
29/// ```http
30/// HTTP/1.1 302 Found
31/// Location: /api/v0/main-frontend/oauth/authorize?client_id=...
32/// ```
33#[instrument(skip(pool))]
34pub async fn approve_consent(
35    pool: web::Data<PgPool>,
36    form: web::Json<ConsentQuery>,
37    user: AuthUser,
38) -> ControllerResult<HttpResponse> {
39    let mut conn = pool.acquire().await?;
40    let token = skip_authorize();
41
42    let client = OAuthClient::find_by_client_id(&mut conn, &form.client_id).await?;
43    if !client.redirect_uris.contains(&form.redirect_uri) {
44        return Err(oauth_invalid_request(
45            "redirect_uri does not match client",
46            None, // Never redirect to an invalid redirect_uri (security)
47            Some(&form.state),
48        ));
49    }
50
51    // Validate requested scopes against client.allowed scopes
52    let requested_scopes: Vec<String> = form
53        .scope
54        .split_whitespace()
55        .map(|s| s.to_string())
56        .collect();
57
58    let allowed_scopes = &client.scopes;
59
60    for scope in &requested_scopes {
61        if !allowed_scopes.contains(scope) {
62            return Err(oauth_invalid_request(
63                "invalid scope",
64                None,
65                Some(&form.state),
66            ));
67        }
68    }
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]
119pub async fn deny_consent(
120    pool: web::Data<PgPool>,
121    form: web::Json<ConsentDenyQuery>,
122) -> Result<HttpResponse, Error> {
123    let mut conn = pool.acquire().await.map_err(|e| {
124        tracing::error!(err = %e, "OAuth consent/deny: pool acquire failed");
125        actix_web::error::ErrorInternalServerError(e)
126    })?;
127
128    let client_result = OAuthClient::find_by_client_id(&mut conn, &form.client_id).await;
129    let client = match client_result {
130        Ok(c) => c,
131        Err(err) => {
132            return Err(match err.error_type() {
133                ModelErrorType::RecordNotFound | ModelErrorType::NotFound => {
134                    actix_web::error::ErrorNotFound("client not found")
135                }
136                _ => {
137                    tracing::error!(err = %err, "OAuth consent/deny: client lookup failed");
138                    actix_web::error::ErrorInternalServerError(err)
139                }
140            });
141        }
142    };
143
144    if !client.redirect_uris.contains(&form.redirect_uri) {
145        return Err(actix_web::error::ErrorBadRequest("invalid redirect URI"));
146    }
147
148    let mut url = Url::parse(&form.redirect_uri)
149        .map_err(|_| actix_web::error::ErrorBadRequest("invalid redirect URI"))?;
150
151    {
152        let mut qp = url.query_pairs_mut();
153        qp.append_pair("error", "access_denied");
154        if !form.state.is_empty() {
155            qp.append_pair("state", &form.state);
156        }
157    }
158
159    Ok(HttpResponse::Found()
160        .append_header(("Location", url.to_string()))
161        .finish())
162}
163
164pub fn _add_routes(cfg: &mut web::ServiceConfig) {
165    cfg.route("/consent", web::post().to(approve_consent))
166        .route("/consent/deny", web::post().to(deny_consent));
167}