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::{oauth_client::OAuthClient, oauth_user_client_scopes::OAuthUserClientScopes};
8use sqlx::PgPool;
9use url::{Url, form_urlencoded};
10
11/// Handles `/consent` approval after the user agrees to grant requested scopes.
12///
13/// This endpoint:
14/// - Validates the redirect URI and requested scopes against the registered client.
15/// - Records granted scopes for the user-client pair.
16/// - Redirects back to `/authorize` to continue the OAuth flow.
17///
18/// # Example
19/// ```http
20/// 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
21/// Cookie: session=abc123
22///
23/// ```
24///
25/// Redirect back to `/authorize`:
26/// ```http
27/// HTTP/1.1 302 Found
28/// Location: /api/v0/main-frontend/oauth/authorize?client_id=...
29/// ```
30#[instrument(skip(pool))]
31pub async fn approve_consent(
32    pool: web::Data<PgPool>,
33    form: web::Json<ConsentQuery>,
34    user: AuthUser,
35) -> ControllerResult<HttpResponse> {
36    let mut conn = pool.acquire().await?;
37    let token = skip_authorize();
38
39    let client = OAuthClient::find_by_client_id(&mut conn, &form.client_id).await?;
40    if !client.redirect_uris.contains(&form.redirect_uri) {
41        return Err(oauth_invalid_request(
42            "redirect_uri does not match client",
43            None, // Never redirect to an invalid redirect_uri (security)
44            Some(&form.state),
45        ));
46    }
47
48    // Validate requested scopes against client.allowed scopes
49    let requested_scopes: Vec<String> = form
50        .scope
51        .split_whitespace()
52        .map(|s| s.to_string())
53        .collect();
54
55    let allowed_scopes = &client.scopes;
56
57    for scope in &requested_scopes {
58        if !allowed_scopes.contains(scope) {
59            return Err(oauth_invalid_request(
60                "invalid scope",
61                None,
62                Some(&form.state),
63            ));
64        }
65    }
66
67    OAuthUserClientScopes::insert(&mut conn, user.id, client.id, &requested_scopes).await?;
68
69    // Redirect to /authorize (the OAuth authorize endpoint typically remains a GET)
70    let mut query_string = String::new();
71    {
72        let mut query_builder = form_urlencoded::Serializer::new(&mut query_string);
73        query_builder
74            .append_pair("client_id", &form.client_id)
75            .append_pair("redirect_uri", &form.redirect_uri)
76            .append_pair("scope", &form.scope)
77            .append_pair("state", &form.state)
78            .append_pair("nonce", &form.nonce)
79            .append_pair("response_type", &form.response_type);
80
81        // Preserve PKCE parameters if present
82        if let Some(code_challenge) = &form.code_challenge {
83            query_builder.append_pair("code_challenge", code_challenge);
84        }
85        if let Some(code_challenge_method) = &form.code_challenge_method {
86            query_builder.append_pair("code_challenge_method", code_challenge_method);
87        }
88    }
89    let query = query_string;
90
91    // Relative Location: browser resolves against current origin
92    let location = format!("/api/v0/main-frontend/oauth/authorize?{}", query);
93
94    token.authorized_ok(HttpResponse::Ok().json(ConsentResponse {
95        redirect_uri: location,
96    }))
97}
98
99/// Handles `/consent/deny` when the user refuses to grant scopes.
100///
101/// This endpoint:
102/// - Redirects back to the client with `error=access_denied`.
103///
104/// # Example
105/// ```http
106/// GET /api/v0/main-frontend/oauth/consent/deny?redirect_uri=http://localhost&state=random123 HTTP/1.1
107///
108/// ```
109///
110/// Response:
111/// ```http
112/// HTTP/1.1 302 Found
113/// Location: http://localhost?error=access_denied&state=random123
114/// ```
115#[instrument]
116pub async fn deny_consent(
117    pool: web::Data<PgPool>,
118    form: web::Json<ConsentDenyQuery>,
119) -> Result<HttpResponse, Error> {
120    let mut conn = pool
121        .acquire()
122        .await
123        .map_err(actix_web::error::ErrorInternalServerError)?;
124
125    let client = OAuthClient::find_by_client_id(&mut conn, &form.client_id)
126        .await
127        .map_err(actix_web::error::ErrorBadRequest)?;
128
129    if !client.redirect_uris.contains(&form.redirect_uri) {
130        return Err(actix_web::error::ErrorBadRequest("invalid redirect URI"));
131    }
132
133    let mut url = Url::parse(&form.redirect_uri)
134        .map_err(|_| actix_web::error::ErrorBadRequest("invalid redirect URI"))?;
135
136    {
137        let mut qp = url.query_pairs_mut();
138        qp.append_pair("error", "access_denied");
139        if !form.state.is_empty() {
140            qp.append_pair("state", &form.state);
141        }
142    }
143
144    Ok(HttpResponse::Found()
145        .append_header(("Location", url.to_string()))
146        .finish())
147}
148
149pub fn _add_routes(cfg: &mut web::ServiceConfig) {
150    cfg.route("/consent", web::post().to(approve_consent))
151        .route("/consent/deny", web::post().to(deny_consent));
152}