Skip to main content

headless_lms_server/controllers/main_frontend/oauth/
token.rs

1use crate::domain::oauth::dpop::verify_dpop_from_actix_for_token;
2use crate::domain::oauth::errors::TokenGrantError;
3use crate::domain::oauth::helpers::{
4    ClientAuthError, authenticate_oauth_client, oauth_invalid_client, ok_json_no_cache,
5    scope_has_openid,
6};
7use crate::domain::oauth::oauth_validated::OAuthValidated;
8use crate::domain::oauth::oidc::generate_id_token;
9use crate::domain::oauth::token_query::TokenQuery;
10use crate::domain::oauth::token_response::TokenResponse;
11use crate::domain::oauth::token_service::{
12    TokenGrantRequest, TokenGrantResult, generate_token_pair, process_token_grant,
13};
14use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig};
15use crate::prelude::*;
16use actix_web::{HttpResponse, web};
17use chrono::{Duration, Utc};
18use domain::error::{OAuthErrorCode, OAuthErrorData};
19use headless_lms_base::config::ApplicationConfiguration;
20use headless_lms_utils::cache::Cache;
21use models::oauth_access_token::TokenType;
22use sqlx::PgPool;
23use utoipa::OpenApi;
24
25#[derive(OpenApi)]
26#[openapi(paths(token))]
27#[allow(dead_code)]
28pub(crate) struct MainFrontendOauthTokenApiDoc;
29
30/// Handles the `/token` endpoint for exchanging authorization codes or refresh tokens.
31///
32/// This endpoint issues and rotates OAuth 2.0 and OpenID Connect tokens with support for
33/// **PKCE**, **DPoP sender-constrained tokens**, and **ID Token issuance**.
34///
35/// ### Authorization Code Grant
36/// - Validates client credentials (`client_id`, `client_secret`) or public client rules.
37/// - Verifies the authorization code, its redirect URI, PKCE binding (`code_verifier`), and expiration.
38/// - Optionally verifies a DPoP proof and binds the issued tokens to the DPoP JWK thumbprint (`dpop_jkt`).
39/// - Issues a new access token, refresh token, and (for OIDC requests) an ID token.
40///
41/// ### Refresh Token Grant
42/// - Validates the refresh token and client binding.
43/// - Verifies DPoP proof when applicable (must match the original `dpop_jkt`).
44/// - Rotates the refresh token (revokes the old one, inserts a new one linked to it).
45/// - Issues a new access token (and ID token if `openid` scope requested).
46///
47/// ### Security Features
48/// - **PKCE (RFC 7636)**: Enforced for public clients and optionally for confidential ones.
49/// - **DPoP (RFC 9449)**: Sender-constrains tokens to a JWK thumbprint.
50/// - **Refresh Token Rotation**: Prevents replay by revoking old RTs on use.
51/// - **OIDC ID Token**: Issued only if `openid` is in the granted scopes.
52///
53/// Follows:
54/// - [RFC 6749 §3.2 — Token Endpoint](https://datatracker.ietf.org/doc/html/rfc6749#section-3.2)
55/// - [RFC 7636 — PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
56/// - [RFC 9449 — DPoP](https://datatracker.ietf.org/doc/html/rfc9449)
57/// - [OIDC Core §3.1.3 — Token Endpoint](https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint)
58///
59/// # Example
60/// ```http
61/// POST /api/v0/main-frontend/oauth/token HTTP/1.1
62/// Content-Type: application/x-www-form-urlencoded
63///
64/// grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=http://localhost&client_id=test-client-id&client_secret=test-secret&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
65/// ```
66///
67/// Successful response:
68/// ```http
69/// HTTP/1.1 200 OK
70/// Content-Type: application/json
71///
72/// {
73///   "access_token": "2YotnFZFEjr1zCsicMWpAA",
74///   "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
75///   "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
76///   "token_type": "DPoP",
77///   "expires_in": 3600
78/// }
79/// ```
80///
81/// Example error:
82/// ```http
83/// HTTP/1.1 401 Unauthorized
84/// Content-Type: application/json
85///
86/// {
87///   "error": "invalid_client",
88///   "error_description": "invalid client secret"
89/// }
90/// ```
91///
92/// Example DPoP error:
93/// ```http
94/// HTTP/1.1 401 Unauthorized
95/// WWW-Authenticate: DPoP error="use_dpop_proof", error_description="Missing DPoP header"
96/// ```
97#[instrument(skip(pool, app_conf, form, cache))]
98#[utoipa::path(
99    post,
100    path = "/token",
101    operation_id = "exchangeOauthToken",
102    tag = "oauth",
103    request_body(
104        content = serde_json::Value,
105        content_type = "application/x-www-form-urlencoded"
106    ),
107    responses(
108        (status = 200, description = "OAuth token response", body = serde_json::Value),
109        (status = 401, description = "OAuth token error")
110    )
111)]
112pub async fn token(
113    pool: web::Data<PgPool>,
114    OAuthValidated(form): OAuthValidated<TokenQuery>,
115    req: actix_web::HttpRequest,
116    app_conf: web::Data<ApplicationConfiguration>,
117    cache: web::Data<Cache>,
118) -> ControllerResult<HttpResponse> {
119    let mut conn = pool.acquire().await?;
120    let server_token = skip_authorize();
121
122    let access_ttl = Duration::hours(1);
123    let refresh_ttl = Duration::days(30);
124
125    let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
126    let client = authenticate_oauth_client(
127        &mut conn,
128        &form.client_id,
129        form.client_secret.as_ref(),
130        token_hmac_key,
131    )
132    .await
133    .map_err(|e| match e {
134        ClientAuthError::UnknownClient => oauth_invalid_client("invalid client_id"),
135        ClientAuthError::ClientSecretMissing => {
136            oauth_invalid_client("client_secret required for confidential clients")
137        }
138        ClientAuthError::ClientSecretMismatch => oauth_invalid_client("invalid client secret"),
139    })?;
140
141    // Add non-secret fields to the span for observability
142    tracing::Span::current().record("client_id", &form.client_id);
143
144    // Check if client allows this grant type
145    let grant_kind = form.grant.kind();
146    tracing::Span::current().record("grant_type", format!("{:?}", grant_kind));
147    if !client.allows_grant(grant_kind) {
148        return Err(ControllerError::new(
149            ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
150                error: OAuthErrorCode::UnsupportedGrantType.as_str().into(),
151                error_description: "grant type not allowed for this client".into(),
152                redirect_uri: None,
153                state: None,
154                nonce: None,
155            })),
156            "Grant type not allowed for this client",
157            None::<anyhow::Error>,
158        ));
159    }
160
161    // DPoP vs Bearer selection (token endpoint uses deferred replay so use_dpop_nonce does not revoke the auth code)
162    let dpop_jkt_opt = if req.headers().get("DPoP").is_some() {
163        Some(
164            verify_dpop_from_actix_for_token(
165                &mut conn,
166                &req,
167                &app_conf.oauth_server_configuration.dpop_nonce_key,
168            )
169            .await?,
170        )
171    } else {
172        if !client.bearer_allowed {
173            return Err(oauth_invalid_client(
174                "client not allowed to use other than dpop-bound tokens",
175            ));
176        }
177        None
178    };
179
180    let issued_token_type = if dpop_jkt_opt.is_some() {
181        TokenType::DPoP
182    } else {
183        TokenType::Bearer
184    };
185    tracing::Span::current().record("token_type", format!("{:?}", issued_token_type));
186
187    let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
188    let token_pair = generate_token_pair(token_hmac_key);
189    let access_token = token_pair.access_token.clone();
190    let refresh_token = token_pair.refresh_token.clone();
191    let refresh_token_expires_at = Utc::now() + refresh_ttl;
192    let access_expires_at = Utc::now() + access_ttl;
193
194    let request = TokenGrantRequest {
195        grant: &form.grant,
196        client: &client,
197        token_pair,
198        access_expires_at,
199        refresh_expires_at: refresh_token_expires_at,
200        issued_token_type,
201        dpop_jkt: dpop_jkt_opt.as_deref(),
202        token_hmac_key,
203    };
204
205    let TokenGrantResult {
206        user_id,
207        scopes: scope_vec,
208        nonce: nonce_opt,
209        access_expires_at: at_expires_at,
210        issue_id_token,
211    } = process_token_grant(&mut conn, &cache, request)
212        .await
213        .map_err(|e: TokenGrantError| ControllerError::from(e))?;
214
215    let base_url = app_conf.base_url.trim_end_matches('/');
216    let id_token = if issue_id_token && scope_has_openid(&scope_vec) {
217        Some(generate_id_token(
218            user_id,
219            &client.client_id,
220            nonce_opt.as_deref(),
221            at_expires_at,
222            &format!("{}/api/v0/main-frontend/oauth", base_url),
223            &app_conf,
224        )?)
225    } else {
226        None
227    };
228
229    let response = TokenResponse {
230        access_token,
231        refresh_token: Some(refresh_token),
232        id_token,
233        token_type: match issued_token_type {
234            TokenType::Bearer => "Bearer".to_string(),
235            TokenType::DPoP => "DPoP".to_string(),
236        },
237        expires_in: access_ttl.num_seconds() as u32,
238    };
239
240    server_token.authorized_ok(ok_json_no_cache(response))
241}
242
243pub fn _add_routes(cfg: &mut web::ServiceConfig) {
244    cfg.service(
245        web::resource("/token")
246            .wrap(RateLimit::new(RateLimitConfig {
247                per_minute: Some(100),
248                per_hour: Some(500),
249                per_day: Some(2000),
250                per_month: None,
251                ..Default::default()
252            }))
253            .route(web::post().to(token)),
254    );
255}