Skip to main content

headless_lms_server/controllers/main_frontend/oauth/
consent.rs

1use crate::domain::oauth::authorize_query::AuthorizeParams;
2use crate::domain::oauth::consent_deny_query::ConsentDenyQuery;
3use crate::domain::oauth::consent_query::ConsentQuery;
4use crate::domain::oauth::consent_response::ConsentResponse;
5use crate::domain::oauth::helpers::{oauth_invalid_request, split_and_validate_scopes};
6use crate::domain::oauth::redirects::{
7    build_authorize_qs, error_redirect_url, is_loopback_host, redirect_uri_matches,
8};
9use crate::prelude::*;
10use actix_web::{HttpResponse, web};
11use domain::error::OAuthErrorCode;
12use models::{
13    library::oauth::mcp::McpScope, oauth_client::OAuthClient,
14    oauth_user_client_scopes::OAuthUserClientScopes,
15};
16use sqlx::PgPool;
17use url::Url;
18use utoipa::{IntoParams, ToSchema};
19
20#[derive(Debug, Deserialize, IntoParams)]
21#[into_params(parameter_in = Query)]
22pub struct ConsentInfoQuery {
23    pub client_id: String,
24    pub redirect_uri: String,
25    /// The requested scopes, space-separated.
26    pub scope: String,
27}
28
29/// One requested scope as the consent screen shows it.
30#[derive(Debug, Serialize, Deserialize, ToSchema)]
31pub struct ConsentScope {
32    pub scope: String,
33    /// The user cannot uncheck it.
34    pub is_required: bool,
35    pub is_checked_by_default: bool,
36    /// The user already granted it to this client.
37    pub was_previously_granted: bool,
38}
39
40/// What the consent screen shows about the client asking for access.
41#[derive(Debug, Serialize, Deserialize, ToSchema)]
42pub struct ConsentInfo {
43    pub client_name: String,
44    pub client_uri: Option<String>,
45    /// The redirect goes to a program on the user's own computer.
46    pub is_loopback_redirect: bool,
47    /// Whether the client registered itself through a client ID metadata document, as AI agents
48    /// do.
49    pub is_metadata_document_client: bool,
50    pub scopes: Vec<ConsentScope>,
51}
52
53/**
54GET `/api/v0/main-frontend/oauth/consent-info` - What the consent screen shows: the client asking
55and a checkbox state per requested scope. The client must already be known, which the authorize
56endpoint ensures before redirecting to the consent screen.
57*/
58#[instrument(skip(pool))]
59#[utoipa::path(
60    get,
61    path = "/consent-info",
62    operation_id = "getOauthConsentInfo",
63    tag = "oauth",
64    params(ConsentInfoQuery),
65    responses(
66        (status = 200, description = "Consent screen data", body = ConsentInfo)
67    )
68)]
69pub async fn get_consent_info(
70    pool: web::Data<PgPool>,
71    query: web::Query<ConsentInfoQuery>,
72    user: AuthUser,
73) -> ControllerResult<web::Json<ConsentInfo>> {
74    let mut conn = pool.acquire().await?;
75    let token = skip_authorize();
76    let client = consent_client(&mut conn, &query.client_id, &query.redirect_uri).await?;
77    let granted = OAuthUserClientScopes::find_scopes(&mut conn, user.id, client.id).await?;
78    let mut requested: Vec<String> = query.scope.split_whitespace().map(str::to_string).collect();
79    if McpScope::lacks_required(requested.iter().map(String::as_str)) {
80        requested.insert(0, McpScope::MaterialRead.as_str().to_string());
81    }
82    let scopes = requested
83        .into_iter()
84        .map(|scope| {
85            let mcp_scope = McpScope::parse(&scope);
86            ConsentScope {
87                is_required: mcp_scope.is_none_or(McpScope::is_required),
88                is_checked_by_default: mcp_scope.is_none_or(McpScope::is_checked_by_default),
89                was_previously_granted: granted.contains(&scope),
90                scope,
91            }
92        })
93        .collect();
94    let is_loopback_redirect = Url::parse(&query.redirect_uri)
95        .map(|url| is_loopback_host(&url))
96        .unwrap_or(false);
97    token.authorized_ok(web::Json(ConsentInfo {
98        is_metadata_document_client: client.is_metadata_document_client(),
99        client_name: client.client_name,
100        client_uri: client.client_uri,
101        is_loopback_redirect,
102        scopes,
103    }))
104}
105
106/// Handles `/consent` approval after the user agrees to grant requested scopes.
107///
108/// This endpoint:
109/// - Validates the redirect URI and the approved scopes against the registered client.
110/// - Records the approved scopes for the user-client pair.
111/// - Redirects back to `/authorize` with the approved scopes, so the code is issued for them.
112///
113/// `scope` is what the user approved, which may be fewer scopes than requested. If it contains
114/// any MCP scope it must contain `material:read`.
115///
116/// # Example
117/// ```http
118/// POST /api/v0/main-frontend/oauth/consent HTTP/1.1
119/// Cookie: session=abc123
120///
121/// {"client_id":"test-client-id","redirect_uri":"http://localhost","scope":"openid profile","state":"random123","nonce":"secure_nonce_abc","response_type":"code"}
122/// ```
123///
124/// Response:
125/// ```json
126/// {"redirect_uri": "/api/v0/main-frontend/oauth/authorize?client_id=..."}
127/// ```
128#[instrument(skip(pool))]
129#[utoipa::path(
130    post,
131    path = "/consent",
132    operation_id = "approveOauthConsent",
133    tag = "oauth",
134    request_body = ConsentQuery,
135    responses(
136        (status = 200, description = "Consent approval response", body = ConsentResponse)
137    )
138)]
139pub async fn approve_consent(
140    pool: web::Data<PgPool>,
141    form: web::Json<ConsentQuery>,
142    user: AuthUser,
143) -> ControllerResult<HttpResponse> {
144    let mut conn = pool.acquire().await?;
145    let token = skip_authorize();
146
147    let client = consent_client(&mut conn, &form.client_id, &form.redirect_uri).await?;
148
149    let approved_scopes = split_and_validate_scopes(&form.scope, &client.scopes)
150        .map_err(|_| oauth_invalid_request("invalid scope", None, Some(&form.state)))?;
151    if approved_scopes.is_empty() {
152        return Err(oauth_invalid_request(
153            "no scope approved",
154            None,
155            Some(&form.state),
156        ));
157    }
158    if McpScope::lacks_required(approved_scopes.iter().map(String::as_str)) {
159        return Err(oauth_invalid_request(
160            "material:read is required",
161            None,
162            Some(&form.state),
163        ));
164    }
165
166    OAuthUserClientScopes::insert(&mut conn, user.id, client.id, &approved_scopes).await?;
167
168    let approved = AuthorizeParams {
169        response_type: form.response_type.clone(),
170        client_id: form.client_id.clone(),
171        redirect_uri: form.redirect_uri.clone(),
172        scope: approved_scopes.join(" "),
173        state: Some(form.state.clone()),
174        nonce: Some(form.nonce.clone()),
175        code_challenge: form.code_challenge.clone(),
176        code_challenge_method: form.code_challenge_method.clone(),
177        prompt: None,
178        request: None,
179        resource: form.resource.clone(),
180    };
181    // Relative Location: browser resolves against current origin
182    let location = format!(
183        "/api/v0/main-frontend/oauth/authorize?{}",
184        build_authorize_qs(&approved)
185    );
186
187    token.authorized_ok(HttpResponse::Ok().json(ConsentResponse {
188        redirect_uri: location,
189    }))
190}
191
192/// Handles `/consent/deny` when the user refuses to grant scopes.
193///
194/// This endpoint:
195/// - Redirects back to the client with `error=access_denied` and `iss` (RFC 9207).
196///
197/// # Example
198/// ```http
199/// POST /api/v0/main-frontend/oauth/consent/deny HTTP/1.1
200///
201/// {"client_id":"test-client-id","redirect_uri":"http://localhost","state":"random123"}
202/// ```
203///
204/// Response:
205/// ```json
206/// {"redirect_uri": "http://localhost?error=access_denied&state=random123&iss=..."}
207/// ```
208#[instrument(skip(pool, app_conf))]
209#[utoipa::path(
210    post,
211    path = "/consent/deny",
212    operation_id = "denyOauthConsent",
213    tag = "oauth",
214    request_body = ConsentDenyQuery,
215    responses(
216        (status = 200, description = "Consent denial response", body = ConsentResponse)
217    )
218)]
219pub async fn deny_consent(
220    pool: web::Data<PgPool>,
221    form: web::Json<ConsentDenyQuery>,
222    app_conf: web::Data<ApplicationConfiguration>,
223) -> ControllerResult<HttpResponse> {
224    let mut conn = pool.acquire().await?;
225    let token = skip_authorize();
226    consent_client(&mut conn, &form.client_id, &form.redirect_uri).await?;
227    let redirect_uri = error_redirect_url(
228        &form.redirect_uri,
229        OAuthErrorCode::AccessDenied.as_str(),
230        None,
231        Some(form.state.as_str()).filter(|state| !state.is_empty()),
232        Some(&app_conf.oauth_issuer()),
233    )
234    .ok_or_else(|| oauth_invalid_request("invalid redirect URI", None, None))?;
235    token.authorized_ok(HttpResponse::Ok().json(ConsentResponse { redirect_uri }))
236}
237
238/// The client a consent request names, provided `redirect_uri` is one of the client's.
239async fn consent_client(
240    conn: &mut PgConnection,
241    client_id: &str,
242    redirect_uri: &str,
243) -> Result<OAuthClient, ControllerError> {
244    let client = OAuthClient::find_by_client_id(conn, client_id).await?;
245    if !redirect_uri_matches(&client, redirect_uri) {
246        return Err(oauth_invalid_request(
247            "redirect_uri does not match client",
248            None,
249            None,
250        ));
251    }
252    Ok(client)
253}
254
255pub fn _add_routes(cfg: &mut web::ServiceConfig) {
256    cfg.route("/consent", web::post().to(approve_consent))
257        .route("/consent/deny", web::post().to(deny_consent))
258        .route("/consent-info", web::get().to(get_consent_info));
259}