headless_lms_server/controllers/main_frontend/oauth/
consent.rs1use 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 pub scope: String,
27}
28
29#[derive(Debug, Serialize, Deserialize, ToSchema)]
31pub struct ConsentScope {
32 pub scope: String,
33 pub is_required: bool,
35 pub is_checked_by_default: bool,
36 pub was_previously_granted: bool,
38}
39
40#[derive(Debug, Serialize, Deserialize, ToSchema)]
42pub struct ConsentInfo {
43 pub client_name: String,
44 pub client_uri: Option<String>,
45 pub is_loopback_redirect: bool,
47 pub is_metadata_document_client: bool,
50 pub scopes: Vec<ConsentScope>,
51}
52
53#[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#[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 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#[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
238async 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}