headless_lms_server/controllers/main_frontend/oauth/
authorize.rs1use crate::domain::oauth::authorize_query::AuthorizeQuery;
2use crate::domain::oauth::helpers::{oauth_error, oauth_invalid_request};
3use crate::domain::oauth::oauth_validated::OAuthValidated;
4use crate::domain::oauth::pkce::parse_authorize_pkce;
5use crate::domain::oauth::redirects::{
6 build_authorize_qs, build_consent_redirect, build_login_redirect, redirect_with_code,
7};
8use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig};
9use crate::prelude::*;
10use actix_web::web;
11use chrono::{Duration, Utc};
12use itertools::Itertools;
13use models::{
14 library::oauth::{generate_access_token, token_digest_sha256},
15 oauth_auth_code::{NewAuthCodeParams, OAuthAuthCode},
16 oauth_client::OAuthClient,
17 oauth_user_client_scopes::OAuthUserClientScopes,
18};
19use sqlx::PgPool;
20use std::collections::HashSet;
21use utoipa::OpenApi;
22
23#[derive(OpenApi)]
24#[openapi(paths(authorize_get_doc, authorize_post_doc))]
25#[allow(dead_code)]
26pub(crate) struct MainFrontendOauthAuthorizeApiDoc;
27
28#[derive(Debug, Clone, Copy, Default)]
29struct PromptFlags {
30 none: bool,
31 consent: bool,
32 login: bool,
33 select_account: bool,
34}
35
36fn parse_prompt(prompt: Option<&str>) -> Result<PromptFlags, &'static str> {
37 let mut f = PromptFlags::default();
38 let Some(p) = prompt else { return Ok(f) };
39
40 for v in p.split_whitespace() {
41 match v {
42 "none" => f.none = true,
43 "consent" => f.consent = true,
44 "login" => f.login = true,
45 "select_account" => f.select_account = true,
46 _ => return Err("unsupported prompt value"),
47 }
48 }
49
50 if f.none && (f.consent || f.login || f.select_account) {
51 return Err("prompt=none cannot be combined with other values");
52 }
53
54 Ok(f)
55}
56
57pub async fn authorize(
94 pool: web::Data<PgPool>,
95 OAuthValidated(query): OAuthValidated<AuthorizeQuery>,
96 user: Option<AuthUser>,
97 app_conf: web::Data<headless_lms_base::config::ApplicationConfiguration>,
98) -> ControllerResult<HttpResponse> {
99 let mut conn = pool.acquire().await?;
100 let server_token = skip_authorize();
101
102 let client = OAuthClient::find_by_client_id(&mut conn, &query.client_id)
103 .await
104 .map_err(|e| {
105 tracing::error!(err = %e, "OAuth authorize: client lookup failed");
106 oauth_invalid_request(
107 "invalid client_id",
108 None, query.state.as_deref(),
110 )
111 })?;
112
113 tracing::Span::current().record("client_id", &query.client_id);
115 tracing::Span::current().record("response_type", &query.response_type);
116
117 if !client.redirect_uris.contains(&query.redirect_uri) {
118 return Err(oauth_invalid_request(
119 "redirect_uri does not match client",
120 None, query.state.as_deref(),
122 ));
123 }
124
125 if query.request.is_some() {
126 return Err(oauth_error(
127 "request_not_supported",
128 "request object is not supported",
129 Some(&query.redirect_uri),
130 query.state.as_deref(),
131 ));
132 }
133
134 let prompt = parse_prompt(query.prompt.as_deref()).map_err(|msg| {
135 oauth_invalid_request(msg, Some(&query.redirect_uri), query.state.as_deref())
136 })?;
137
138 if prompt.login {
139 return Err(oauth_error(
140 "inalid_request",
141 "prompt=login is not supported",
142 Some(&query.redirect_uri),
143 query.state.as_deref(),
144 ));
145 }
146
147 if prompt.select_account {
148 return Err(oauth_error(
149 "inalid_request",
150 "prompt=select_account is not supported",
151 Some(&query.redirect_uri),
152 query.state.as_deref(),
153 ));
154 }
155
156 let parsed_pkce_method = parse_authorize_pkce(
157 &client,
158 query.code_challenge.as_deref(),
159 query.code_challenge_method.as_deref(),
160 &query.redirect_uri,
161 query.state.as_deref(),
162 )?;
163
164 let redirect_url = match user {
165 Some(user) => {
166 let granted_scopes: Vec<String> =
167 OAuthUserClientScopes::find_scopes(&mut conn, user.id, client.id).await?;
168
169 let requested: HashSet<&str> = query.scope.split_whitespace().collect();
170 let granted: HashSet<&str> = granted_scopes.iter().map(|s| s.as_str()).collect();
171 let missing: Vec<&str> = requested.difference(&granted).copied().collect();
172 if prompt.none && !missing.is_empty() {
173 return Err(oauth_error(
174 "consent_required",
175 "end-user consent is required",
176 Some(&query.redirect_uri),
177 query.state.as_deref(),
178 ));
179 }
180
181 if prompt.consent || !missing.is_empty() {
182 let return_to = format!(
183 "/api/v0/main-frontend/oauth/authorize?{}",
184 build_authorize_qs(&query)
185 );
186 build_consent_redirect(&query, &return_to)
187 } else {
188 let code = generate_access_token();
189 let expires_at = Utc::now() + Duration::minutes(10);
190 let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
191 let code_digest = token_digest_sha256(&code, token_hmac_key);
192
193 let new_auth_code_params = NewAuthCodeParams {
194 digest: &code_digest,
195 user_id: user.id,
196 client_id: client.id,
197 redirect_uri: &query.redirect_uri,
198 scopes: &query
199 .scope
200 .split_whitespace()
201 .map(|s| s.to_string())
202 .collect_vec(),
203 nonce: query.nonce.as_deref(),
204 code_challenge: query.code_challenge.as_deref(),
205 code_challenge_method: parsed_pkce_method,
206 dpop_jkt: None, expires_at,
208 metadata: serde_json::Map::new(),
209 };
210
211 OAuthAuthCode::insert(&mut conn, new_auth_code_params).await?;
212 redirect_with_code(&query.redirect_uri, &code, query.state.as_deref())
213 }
214 }
215 None => {
216 if prompt.none {
217 return Err(oauth_error(
218 "login_required",
219 "end-user is not logged in",
220 Some(&query.redirect_uri),
221 query.state.as_deref(),
222 ));
223 }
224 build_login_redirect(&query)
225 }
226 };
227
228 server_token.authorized_ok(
229 HttpResponse::Found()
230 .append_header(("Location", redirect_url))
231 .finish(),
232 )
233}
234
235#[utoipa::path(
236 get,
237 path = "/authorize",
238 operation_id = "authorizeOauthGet",
239 tag = "oauth",
240 params(
241 ("response_type" = Option<String>, Query, description = "OAuth response type"),
242 ("client_id" = Option<String>, Query, description = "OAuth client id"),
243 ("redirect_uri" = Option<String>, Query, description = "Redirect URI"),
244 ("scope" = Option<String>, Query, description = "Requested scopes"),
245 ("state" = Option<String>, Query, description = "OAuth state"),
246 ("nonce" = Option<String>, Query, description = "OpenID Connect nonce"),
247 ("code_challenge" = Option<String>, Query, description = "PKCE code challenge"),
248 ("code_challenge_method" = Option<String>, Query, description = "PKCE code challenge method"),
249 ("prompt" = Option<String>, Query, description = "Prompt behavior"),
250 ("request" = Option<String>, Query, description = "Unsupported request object")
251 ),
252 responses(
253 (status = 302, description = "Redirect to login, consent, or client redirect URI")
254 )
255)]
256#[allow(dead_code)]
257pub(crate) fn authorize_get_doc() {}
258
259#[utoipa::path(
260 post,
261 path = "/authorize",
262 operation_id = "authorizeOauthPost",
263 tag = "oauth",
264 request_body(
265 content = serde_json::Value,
266 content_type = "application/x-www-form-urlencoded"
267 ),
268 responses(
269 (status = 302, description = "Redirect to login, consent, or client redirect URI")
270 )
271)]
272#[allow(dead_code)]
273pub(crate) fn authorize_post_doc() {}
274
275pub fn _add_routes(cfg: &mut web::ServiceConfig) {
276 cfg.service(
277 web::resource("/authorize")
278 .wrap(RateLimit::new(RateLimitConfig {
279 per_minute: Some(100),
280 per_hour: Some(500),
281 per_day: Some(2000),
282 per_month: None,
283 ..Default::default()
284 }))
285 .route(web::get().to(authorize))
286 .route(web::post().to(authorize)),
287 );
288}