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::{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/// Handles the `/token` endpoint for exchanging authorization codes or refresh tokens.
30///
31/// This endpoint issues and rotates OAuth 2.0 and OpenID Connect tokens with support for
32/// **PKCE**, **DPoP sender-constrained tokens**, and **ID Token issuance**.
33///
34/// ### Authorization Code Grant
35/// - Validates client credentials (`client_id`, `client_secret`) or public client rules.
36/// - Verifies the authorization code, its redirect URI, PKCE binding (`code_verifier`), and expiration.
37/// - Optionally verifies a DPoP proof and binds the issued tokens to the DPoP JWK thumbprint (`dpop_jkt`).
38/// - Issues a new access token, refresh token, and (for OIDC requests) an ID token.
39///
40/// ### Refresh Token Grant
41/// - Validates the refresh token and client binding.
42/// - Verifies DPoP proof when applicable (must match the original `dpop_jkt`).
43/// - Rotates the refresh token (revokes the old one, inserts a new one linked to it).
44/// - Issues a new access token (and ID token if `openid` scope requested).
45///
46/// ### Security Features
47/// - **PKCE (RFC 7636)**: Enforced for public clients and optionally for confidential ones.
48/// - **DPoP (RFC 9449)**: Sender-constrains tokens to a JWK thumbprint.
49/// - **Refresh Token Rotation**: Prevents replay by revoking old RTs on use.
50/// - **OIDC ID Token**: Issued only if `openid` is in the granted scopes.
51///
52/// Follows:
53/// - [RFC 6749 §3.2 — Token Endpoint](https://datatracker.ietf.org/doc/html/rfc6749#section-3.2)
54/// - [RFC 7636 — PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
55/// - [RFC 9449 — DPoP](https://datatracker.ietf.org/doc/html/rfc9449)
56/// - [OIDC Core §3.1.3 — Token Endpoint](https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint)
57///
58/// # Example
59/// ```http
60/// POST /api/v0/main-frontend/oauth/token HTTP/1.1
61/// Content-Type: application/x-www-form-urlencoded
62///
63/// grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=http://localhost&client_id=test-client-id&client_secret=test-secret&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
64/// ```
65///
66/// Successful response:
67/// ```http
68/// HTTP/1.1 200 OK
69/// Content-Type: application/json
70///
71/// {
72///   "access_token": "2YotnFZFEjr1zCsicMWpAA",
73///   "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
74///   "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
75///   "token_type": "DPoP",
76///   "expires_in": 3600
77/// }
78/// ```
79///
80/// Example error:
81/// ```http
82/// HTTP/1.1 401 Unauthorized
83/// Content-Type: application/json
84///
85/// {
86///   "error": "invalid_client",
87///   "error_description": "invalid client secret"
88/// }
89/// ```
90///
91/// Example DPoP error:
92/// ```http
93/// HTTP/1.1 401 Unauthorized
94/// WWW-Authenticate: DPoP error="use_dpop_proof", error_description="Missing DPoP header"
95/// ```
96#[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    // Add non-secret fields to the span for observability
131    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    // Check if client allows this grant type
156    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    // DPoP vs Bearer selection (token endpoint uses deferred replay so use_dpop_nonce does not revoke the auth code)
173    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}