1use crate::domain::oauth::authorize_query::{AuthorizeParams, AuthorizeQuery};
2use crate::domain::oauth::client_metadata_document::{self, is_metadata_document_client_id};
3use crate::domain::oauth::helpers::{oauth_error, oauth_invalid_request};
4use crate::domain::oauth::oauth_validated::OAuthValidated;
5use crate::domain::oauth::pkce::parse_authorize_pkce;
6use crate::domain::oauth::redirects::{
7 build_authorize_qs, build_consent_redirect, build_login_redirect, redirect_uri_matches,
8 redirect_with_code,
9};
10use crate::domain::oauth::resource_indicator::ResourceIndicator;
11use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig};
12use crate::prelude::*;
13use actix_web::web;
14use chrono::{Duration, Utc};
15use domain::error::OAuthErrorCode;
16use itertools::Itertools;
17use models::{
18 library::oauth::{generate_access_token, mcp::McpScope, token_digest_sha256},
19 oauth_auth_code::{NewAuthCodeParams, OAuthAuthCode},
20 oauth_client::OAuthClient,
21 oauth_user_client_scopes::OAuthUserClientScopes,
22};
23use sqlx::PgPool;
24use std::collections::HashSet;
25use utoipa::OpenApi;
26
27#[derive(OpenApi)]
28#[openapi(paths(authorize_get_doc, authorize_post_doc))]
29#[allow(dead_code)]
30pub(crate) struct MainFrontendOauthAuthorizeApiDoc;
31
32#[derive(Debug, Clone, Copy, Default)]
33struct PromptFlags {
34 none: bool,
35 consent: bool,
36 login: bool,
37 select_account: bool,
38}
39
40fn parse_prompt(prompt: Option<&str>) -> Result<PromptFlags, &'static str> {
41 let mut f = PromptFlags::default();
42 let Some(p) = prompt else { return Ok(f) };
43
44 for v in p.split_whitespace() {
45 match v {
46 "none" => f.none = true,
47 "consent" => f.consent = true,
48 "login" => f.login = true,
49 "select_account" => f.select_account = true,
50 _ => return Err("unsupported prompt value"),
51 }
52 }
53
54 if f.none && (f.consent || f.login || f.select_account) {
55 return Err("prompt=none cannot be combined with other values");
56 }
57
58 Ok(f)
59}
60
61fn check_resource(
64 client: &OAuthClient,
65 query: &AuthorizeParams,
66 app_conf: &ApplicationConfiguration,
67) -> Result<(), ControllerError> {
68 let redirect = Some(query.redirect_uri.as_str());
69 let state = query.state.as_deref();
70 let Some(resource) = &query.resource else {
71 let requests_mcp_scope = query
72 .scope
73 .split_whitespace()
74 .any(|scope| McpScope::parse(scope).is_some());
75 if requests_mcp_scope {
76 return Err(oauth_invalid_request(
77 "resource is required for MCP scopes",
78 redirect,
79 state,
80 ));
81 }
82 return Ok(());
83 };
84 if resource.as_str() != app_conf.mcp_resource_url() {
85 return Err(oauth_error(
86 OAuthErrorCode::InvalidTarget,
87 "unknown resource",
88 redirect,
89 state,
90 ));
91 }
92 if !client.is_metadata_document_client() {
93 return Err(oauth_error(
94 OAuthErrorCode::InvalidTarget,
95 "the MCP server accepts only clients registered with a client ID metadata document",
96 redirect,
97 state,
98 ));
99 }
100 Ok(())
101}
102
103fn anonymous_response(
107 query: &AuthorizeParams,
108 is_prompt_none: bool,
109 error_redirect: Option<&str>,
110) -> Result<String, ControllerError> {
111 if is_prompt_none {
112 return Err(oauth_error(
113 OAuthErrorCode::LoginRequired,
114 "end-user is not logged in",
115 error_redirect,
116 query.state.as_deref(),
117 ));
118 }
119 Ok(build_login_redirect(query))
120}
121
122pub async fn authorize(
159 pool: web::Data<PgPool>,
160 OAuthValidated(query): OAuthValidated<AuthorizeQuery>,
161 user: Option<AuthUser>,
162 app_conf: web::Data<headless_lms_base::config::ApplicationConfiguration>,
163) -> ControllerResult<HttpResponse> {
164 let issuer = app_conf.oauth_issuer();
165 respond_to_authorize(pool, query, user, app_conf)
166 .await
167 .map_err(|err| err.with_oauth_issuer(issuer))
168}
169
170async fn respond_to_authorize(
171 pool: web::Data<PgPool>,
172 query: AuthorizeParams,
173 user: Option<AuthUser>,
174 app_conf: web::Data<headless_lms_base::config::ApplicationConfiguration>,
175) -> ControllerResult<HttpResponse> {
176 let server_token = skip_authorize();
177 let resolved_client = match &user {
179 Some(user) if is_metadata_document_client_id(&query.client_id) => {
180 Some(client_metadata_document::resolve_client(&pool, &query.client_id, user.id).await?)
181 }
182 _ => None,
183 };
184 let mut conn = pool.acquire().await?;
185
186 let client = if let Some(client) = resolved_client {
187 client
188 } else if is_metadata_document_client_id(&query.client_id) {
189 match client_metadata_document::find_cached_client(&mut conn, &query.client_id).await? {
192 Some(client) => client,
193 None => {
194 let is_prompt_none =
195 parse_prompt(query.prompt.as_deref()).is_ok_and(|prompt| prompt.none);
196 let location = anonymous_response(&query, is_prompt_none, None)?;
197 return server_token.authorized_ok(
198 HttpResponse::Found()
199 .append_header(("Location", location))
200 .finish(),
201 );
202 }
203 }
204 } else {
205 OAuthClient::find_by_client_id(&mut conn, &query.client_id)
206 .await
207 .map_err(|e| {
208 tracing::error!(err = %e, "OAuth authorize: client lookup failed");
209 oauth_invalid_request(
210 "invalid client_id",
211 None, query.state.as_deref(),
213 )
214 })?
215 };
216
217 tracing::Span::current().record("client_id", &query.client_id);
219 tracing::Span::current().record("response_type", &query.response_type);
220
221 if !redirect_uri_matches(&client, &query.redirect_uri) {
222 return Err(oauth_invalid_request(
223 "redirect_uri does not match client",
224 None, query.state.as_deref(),
226 ));
227 }
228
229 check_resource(&client, &query, &app_conf)?;
230
231 if query.request.is_some() {
232 return Err(oauth_error(
233 OAuthErrorCode::RequestNotSupported,
234 "request object is not supported",
235 Some(&query.redirect_uri),
236 query.state.as_deref(),
237 ));
238 }
239
240 let prompt = parse_prompt(query.prompt.as_deref()).map_err(|msg| {
241 oauth_invalid_request(msg, Some(&query.redirect_uri), query.state.as_deref())
242 })?;
243
244 if prompt.login {
245 return Err(oauth_invalid_request(
246 "prompt=login is not supported",
247 Some(&query.redirect_uri),
248 query.state.as_deref(),
249 ));
250 }
251
252 if prompt.select_account {
253 return Err(oauth_invalid_request(
254 "prompt=select_account is not supported",
255 Some(&query.redirect_uri),
256 query.state.as_deref(),
257 ));
258 }
259
260 let parsed_pkce_method = parse_authorize_pkce(
261 &client,
262 query.code_challenge.as_deref(),
263 query.code_challenge_method.as_deref(),
264 &query.redirect_uri,
265 query.state.as_deref(),
266 )?;
267
268 let redirect_url = match user {
269 Some(user) => {
270 let granted_scopes: Vec<String> =
271 OAuthUserClientScopes::find_scopes(&mut conn, user.id, client.id).await?;
272
273 let requested: HashSet<&str> = query.scope.split_whitespace().collect();
274 let granted: HashSet<&str> = granted_scopes.iter().map(|s| s.as_str()).collect();
275 let missing: Vec<&str> = requested.difference(&granted).copied().collect();
276 if prompt.none && !missing.is_empty() {
277 return Err(oauth_error(
278 OAuthErrorCode::ConsentRequired,
279 "end-user consent is required",
280 Some(&query.redirect_uri),
281 query.state.as_deref(),
282 ));
283 }
284
285 if prompt.consent || !missing.is_empty() {
286 let return_to = format!(
287 "/api/v0/main-frontend/oauth/authorize?{}",
288 build_authorize_qs(&query)
289 );
290 build_consent_redirect(&query, &return_to)
291 } else {
292 let code = generate_access_token();
293 let expires_at = Utc::now() + Duration::minutes(10);
294 let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
295 let code_digest = token_digest_sha256(&code, token_hmac_key);
296
297 let new_auth_code_params = NewAuthCodeParams {
298 digest: &code_digest,
299 user_id: user.id,
300 client_id: client.id,
301 redirect_uri: &query.redirect_uri,
302 scopes: &query
303 .scope
304 .split_whitespace()
305 .map(|s| s.to_string())
306 .collect_vec(),
307 nonce: query.nonce.as_deref(),
308 code_challenge: query.code_challenge.as_deref(),
309 code_challenge_method: parsed_pkce_method,
310 dpop_jkt: None, expires_at,
312 metadata: serde_json::Map::new(),
313 resource: query.resource.as_ref().map(ResourceIndicator::as_str),
314 };
315
316 OAuthAuthCode::insert(&mut conn, new_auth_code_params).await?;
317 redirect_with_code(
318 &query.redirect_uri,
319 &code,
320 query.state.as_deref(),
321 &app_conf.oauth_issuer(),
322 )
323 }
324 }
325 None => anonymous_response(&query, prompt.none, Some(&query.redirect_uri))?,
326 };
327
328 server_token.authorized_ok(
329 HttpResponse::Found()
330 .append_header(("Location", redirect_url))
331 .finish(),
332 )
333}
334
335#[utoipa::path(
336 get,
337 path = "/authorize",
338 operation_id = "authorizeOauthGet",
339 tag = "oauth",
340 params(
341 ("response_type" = Option<String>, Query, description = "OAuth response type"),
342 ("client_id" = Option<String>, Query, description = "OAuth client id"),
343 ("redirect_uri" = Option<String>, Query, description = "Redirect URI"),
344 ("scope" = Option<String>, Query, description = "Requested scopes"),
345 ("state" = Option<String>, Query, description = "OAuth state"),
346 ("nonce" = Option<String>, Query, description = "OpenID Connect nonce"),
347 ("code_challenge" = Option<String>, Query, description = "PKCE code challenge"),
348 ("code_challenge_method" = Option<String>, Query, description = "PKCE code challenge method"),
349 ("prompt" = Option<String>, Query, description = "Prompt behavior"),
350 ("request" = Option<String>, Query, description = "Unsupported request object")
351 ),
352 responses(
353 (status = 302, description = "Redirect to login, consent, or client redirect URI")
354 )
355)]
356#[allow(dead_code)]
357pub(crate) fn authorize_get_doc() {}
358
359#[utoipa::path(
360 post,
361 path = "/authorize",
362 operation_id = "authorizeOauthPost",
363 tag = "oauth",
364 request_body(
365 content = serde_json::Value,
366 content_type = "application/x-www-form-urlencoded"
367 ),
368 responses(
369 (status = 302, description = "Redirect to login, consent, or client redirect URI")
370 )
371)]
372#[allow(dead_code)]
373pub(crate) fn authorize_post_doc() {}
374
375pub fn _add_routes(cfg: &mut web::ServiceConfig) {
376 cfg.service(
377 web::resource("/authorize")
378 .wrap(RateLimit::new(RateLimitConfig {
379 per_minute: Some(100),
380 per_hour: Some(500),
381 per_day: Some(2000),
382 per_month: None,
383 ..Default::default()
384 }))
385 .route(web::get().to(authorize))
386 .route(web::post().to(authorize)),
387 );
388}