headless_lms_server/controllers/main_frontend/oauth/
token.rs

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