headless_lms_server/controllers/main_frontend/oauth/
token.rs1use crate::domain::oauth::dpop::verify_dpop_from_actix_for_token;
2use crate::domain::oauth::errors::TokenGrantError;
3use crate::domain::oauth::helpers::{oauth_invalid_client, ok_json_no_cache, scope_has_openid};
4use crate::domain::oauth::oauth_validated::OAuthValidated;
5use crate::domain::oauth::oidc::generate_id_token;
6use crate::domain::oauth::token_query::TokenQuery;
7use crate::domain::oauth::token_response::TokenResponse;
8use crate::domain::oauth::token_service::{
9 TokenGrantRequest, TokenGrantResult, generate_token_pair, process_token_grant,
10};
11use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig};
12use crate::prelude::*;
13use actix_web::{HttpResponse, web};
14use chrono::{Duration, Utc};
15use domain::error::{OAuthErrorCode, OAuthErrorData};
16use headless_lms_base::config::ApplicationConfiguration;
17use models::{
18 library::oauth::token_digest_sha256, oauth_access_token::TokenType, oauth_client::OAuthClient,
19};
20use secrecy::ExposeSecret;
21use sqlx::PgPool;
22use utoipa::OpenApi;
23
24#[derive(OpenApi)]
25#[openapi(paths(token))]
26#[allow(dead_code)]
27pub(crate) struct MainFrontendOauthTokenApiDoc;
28
29#[instrument(skip(pool, app_conf, form))]
97#[utoipa::path(
98 post,
99 path = "/token",
100 operation_id = "exchangeOauthToken",
101 tag = "oauth",
102 request_body(
103 content = serde_json::Value,
104 content_type = "application/x-www-form-urlencoded"
105 ),
106 responses(
107 (status = 200, description = "OAuth token response", body = serde_json::Value),
108 (status = 401, description = "OAuth token error")
109 )
110)]
111pub async fn token(
112 pool: web::Data<PgPool>,
113 OAuthValidated(form): OAuthValidated<TokenQuery>,
114 req: actix_web::HttpRequest,
115 app_conf: web::Data<ApplicationConfiguration>,
116) -> ControllerResult<HttpResponse> {
117 let mut conn = pool.acquire().await?;
118 let server_token = skip_authorize();
119
120 let access_ttl = Duration::hours(1);
121 let refresh_ttl = Duration::days(30);
122
123 let client = OAuthClient::find_by_client_id(&mut conn, &form.client_id)
124 .await
125 .map_err(|e| {
126 tracing::error!(err = %e, "OAuth token: client lookup failed");
127 oauth_invalid_client("invalid client_id")
128 })?;
129
130 tracing::Span::current().record("client_id", &form.client_id);
132
133 if client.is_confidential() {
134 match client.client_secret {
135 Some(ref secret) => {
136 let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
137 if !secret.constant_eq(&token_digest_sha256(
138 form.client_secret
139 .as_ref()
140 .map(|s| s.expose_secret())
141 .unwrap_or_default(),
142 token_hmac_key,
143 )) {
144 return Err(oauth_invalid_client("invalid client secret"));
145 }
146 }
147 None => {
148 return Err(oauth_invalid_client(
149 "client_secret required for confidential clients",
150 ));
151 }
152 }
153 }
154
155 let grant_kind = form.grant.kind();
157 tracing::Span::current().record("grant_type", format!("{:?}", grant_kind));
158 if !client.allows_grant(grant_kind) {
159 return Err(ControllerError::new(
160 ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
161 error: OAuthErrorCode::UnsupportedGrantType.as_str().into(),
162 error_description: "grant type not allowed for this client".into(),
163 redirect_uri: None,
164 state: None,
165 nonce: None,
166 })),
167 "Grant type not allowed for this client",
168 None::<anyhow::Error>,
169 ));
170 }
171
172 let dpop_jkt_opt = if req.headers().get("DPoP").is_some() {
174 Some(
175 verify_dpop_from_actix_for_token(
176 &mut conn,
177 &req,
178 &app_conf.oauth_server_configuration.dpop_nonce_key,
179 )
180 .await?,
181 )
182 } else {
183 if !client.bearer_allowed {
184 return Err(oauth_invalid_client(
185 "client not allowed to use other than dpop-bound tokens",
186 ));
187 }
188 None
189 };
190
191 let issued_token_type = if dpop_jkt_opt.is_some() {
192 TokenType::DPoP
193 } else {
194 TokenType::Bearer
195 };
196 tracing::Span::current().record("token_type", format!("{:?}", issued_token_type));
197
198 let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
199 let token_pair = generate_token_pair(token_hmac_key);
200 let access_token = token_pair.access_token.clone();
201 let refresh_token = token_pair.refresh_token.clone();
202 let refresh_token_expires_at = Utc::now() + refresh_ttl;
203 let access_expires_at = Utc::now() + access_ttl;
204
205 let request = TokenGrantRequest {
206 grant: &form.grant,
207 client: &client,
208 token_pair,
209 access_expires_at,
210 refresh_expires_at: refresh_token_expires_at,
211 issued_token_type,
212 dpop_jkt: dpop_jkt_opt.as_deref(),
213 token_hmac_key,
214 };
215
216 let TokenGrantResult {
217 user_id,
218 scopes: scope_vec,
219 nonce: nonce_opt,
220 access_expires_at: at_expires_at,
221 issue_id_token,
222 } = process_token_grant(&mut conn, request)
223 .await
224 .map_err(|e: TokenGrantError| ControllerError::from(e))?;
225
226 let base_url = app_conf.base_url.trim_end_matches('/');
227 let id_token = if issue_id_token && scope_has_openid(&scope_vec) {
228 Some(generate_id_token(
229 user_id,
230 &client.client_id,
231 nonce_opt.as_deref(),
232 at_expires_at,
233 &format!("{}/api/v0/main-frontend/oauth", base_url),
234 &app_conf,
235 )?)
236 } else {
237 None
238 };
239
240 let response = TokenResponse {
241 access_token,
242 refresh_token: Some(refresh_token),
243 id_token,
244 token_type: match issued_token_type {
245 TokenType::Bearer => "Bearer".to_string(),
246 TokenType::DPoP => "DPoP".to_string(),
247 },
248 expires_in: access_ttl.num_seconds() as u32,
249 };
250
251 server_token.authorized_ok(ok_json_no_cache(response))
252}
253
254pub fn _add_routes(cfg: &mut web::ServiceConfig) {
255 cfg.service(
256 web::resource("/token")
257 .wrap(RateLimit::new(RateLimitConfig {
258 per_minute: Some(100),
259 per_hour: Some(500),
260 per_day: Some(2000),
261 per_month: None,
262 ..Default::default()
263 }))
264 .route(web::post().to(token)),
265 );
266}