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