headless_lms_server/controllers/main_frontend/oauth/
introspect.rs

1use crate::domain::oauth::introspect_query::IntrospectQuery;
2use crate::domain::oauth::introspect_response::IntrospectResponse;
3use crate::domain::oauth::oauth_validated::OAuthValidated;
4use crate::prelude::*;
5use actix_web::{HttpResponse, web};
6use headless_lms_base::config::ApplicationConfiguration;
7use models::{
8    library::oauth::token_digest_sha256,
9    oauth_access_token::{OAuthAccessToken, TokenType},
10    oauth_client::OAuthClient,
11};
12use sqlx::PgPool;
13use utoipa::OpenApi;
14
15#[derive(OpenApi)]
16#[openapi(paths(introspect))]
17#[allow(dead_code)]
18pub(crate) struct MainFrontendOauthIntrospectApiDoc;
19
20/// Handles the `/introspect` endpoint for OAuth 2.0 token introspection (RFC 7662).
21///
22/// This endpoint allows resource servers to query the authorization server about
23/// the active state and metadata of an access token.
24///
25/// ### Security Features
26/// - Client authentication is required (client_id and client_secret for confidential clients)
27/// - Returns `active: false` for invalid/expired tokens or authentication failures
28///   to prevent token enumeration attacks
29/// - Always returns 200 OK, even for invalid tokens (per RFC 7662)
30///
31/// ### Request Parameters
32/// - `token` (required): The token to be introspected
33/// - `token_type_hint` (optional): Hint about token type ("access_token" or "refresh_token")
34/// - `client_id` (required): Client identifier
35/// - `client_secret` (required for confidential clients): Client secret
36///
37/// ### Response
38/// Returns a JSON object with:
39/// - `active` (bool, required): Whether the token is active
40/// - Additional fields only present if `active: true`:
41///   - `scope`: Space-separated list of scopes
42///   - `client_id`: Client identifier
43///   - `username`/`sub`: User identifier (if token has user)
44///   - `exp`: Expiration timestamp (Unix time)
45///   - `iat`: Issued at timestamp (Unix time)
46///   - `aud`: Audience
47///   - `iss`: Issuer
48///   - `jti`: JWT ID
49///   - `token_type`: "Bearer" or "DPoP"
50///
51/// Follows [RFC 7662 — OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662).
52///
53/// # Example
54/// ```http
55/// POST /api/v0/main-frontend/oauth/introspect HTTP/1.1
56/// Content-Type: application/x-www-form-urlencoded
57///
58/// token=ACCESS_TOKEN&client_id=test-client-id&client_secret=test-secret
59/// ```
60///
61/// Successful response:
62/// ```http
63/// HTTP/1.1 200 OK
64/// Content-Type: application/json
65/// Cache-Control: no-store
66///
67/// {
68///   "active": true,
69///   "scope": "openid profile email",
70///   "client_id": "test-client-id",
71///   "sub": "550e8400-e29b-41d4-a716-446655440000",
72///   "username": "550e8400-e29b-41d4-a716-446655440000",
73///   "exp": 1735689600,
74///   "iat": 1735686000,
75///   "iss": "https://example.com/api/v0/main-frontend/oauth",
76///   "jti": "123e4567-e89b-12d3-a456-426614174000",
77///   "token_type": "Bearer"
78/// }
79/// ```
80///
81/// Inactive token response:
82/// ```http
83/// HTTP/1.1 200 OK
84/// Content-Type: application/json
85/// Cache-Control: no-store
86///
87/// {
88///   "active": false
89/// }
90/// ```
91#[instrument(skip(pool, app_conf, form))]
92#[utoipa::path(
93    post,
94    path = "/introspect",
95    operation_id = "introspectOauthToken",
96    tag = "oauth",
97    request_body(
98        content = serde_json::Value,
99        content_type = "application/x-www-form-urlencoded"
100    ),
101    responses(
102        (status = 200, description = "OAuth token introspection response", body = serde_json::Value)
103    )
104)]
105pub async fn introspect(
106    pool: web::Data<PgPool>,
107    OAuthValidated(form): OAuthValidated<IntrospectQuery>,
108    app_conf: web::Data<ApplicationConfiguration>,
109) -> ControllerResult<HttpResponse> {
110    let mut conn = pool.acquire().await?;
111    let server_token = skip_authorize();
112
113    // Authenticate client
114    // RFC 7662 §2.1: "The authorization server responds with HTTP status code 200
115    // and the introspection result, even if the client authentication failed or
116    // the token is invalid."
117    let client_result = OAuthClient::find_by_client_id(&mut conn, &form.client_id).await;
118
119    // Add non-secret fields to the span for observability
120    tracing::Span::current().record("client_id", &form.client_id);
121
122    // If client not found or secret invalid, return active: false per RFC 7662
123    let client = match client_result {
124        Ok(c) => c,
125        Err(e) => {
126            tracing::debug!(err = %e, "OAuth introspect: client lookup failed (inactive client_id)");
127            // Invalid client_id - return active: false per RFC 7662
128            return server_token.authorized_ok(
129                HttpResponse::Ok()
130                    .insert_header(("Cache-Control", "no-store"))
131                    .json(IntrospectResponse {
132                        active: false,
133                        scope: None,
134                        client_id: None,
135                        username: None,
136                        exp: None,
137                        iat: None,
138                        sub: None,
139                        aud: None,
140                        iss: None,
141                        jti: None,
142                        token_type: None,
143                    }),
144            );
145        }
146    };
147
148    // Validate client secret for confidential clients
149    let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
150    let client_valid = if client.is_confidential() {
151        match &client.client_secret {
152            Some(secret) => {
153                let provided_secret_digest = token_digest_sha256(
154                    &form.client_secret.clone().unwrap_or_default(),
155                    token_hmac_key,
156                );
157                secret.constant_eq(&provided_secret_digest)
158            }
159            None => false,
160        }
161    } else {
162        true // Public clients don't need secret validation
163    };
164
165    // If client secret is invalid, return active: false per RFC 7662
166    if !client_valid {
167        return server_token.authorized_ok(
168            HttpResponse::Ok()
169                .insert_header(("Cache-Control", "no-store"))
170                .json(IntrospectResponse {
171                    active: false,
172                    scope: None,
173                    client_id: None,
174                    username: None,
175                    exp: None,
176                    iat: None,
177                    sub: None,
178                    aud: None,
179                    iss: None,
180                    jti: None,
181                    token_type: None,
182                }),
183        );
184    }
185
186    // Hash the provided token to get digest
187    let token_digest = token_digest_sha256(&form.token, token_hmac_key);
188
189    // Look up the access token (only access tokens are supported)
190    let access_token_result = OAuthAccessToken::find_valid(&mut conn, token_digest).await;
191
192    // If token not found or expired, return active: false
193    let access_token = match access_token_result {
194        Ok(token) => token,
195        Err(e) => {
196            tracing::debug!(err = %e, "OAuth introspect: access token lookup failed (inactive/expired token)");
197            return server_token.authorized_ok(
198                HttpResponse::Ok()
199                    .insert_header(("Cache-Control", "no-store"))
200                    .json(IntrospectResponse {
201                        active: false,
202                        scope: None,
203                        client_id: None,
204                        username: None,
205                        exp: None,
206                        iat: None,
207                        sub: None,
208                        aud: None,
209                        iss: None,
210                        jti: None,
211                        token_type: None,
212                    }),
213            );
214        }
215    };
216
217    // Add token type to span for observability
218    tracing::Span::current().record("token_type", format!("{:?}", access_token.token_type));
219    tracing::Span::current().record("token_active", "true");
220
221    // Fetch the client that originally issued the token (not the introspecting client)
222    let token_client = OAuthClient::find_by_id(&mut conn, access_token.client_id).await?;
223
224    // Build response with token metadata
225    let base_url = app_conf.base_url.trim_end_matches('/');
226    let issuer = format!("{}/api/v0/main-frontend/oauth", base_url);
227
228    let response = IntrospectResponse {
229        active: true,
230        scope: Some(access_token.scopes.join(" ")),
231        client_id: Some(token_client.client_id.clone()),
232        username: access_token.user_id.map(|id| id.to_string()),
233        exp: Some(access_token.expires_at.timestamp()),
234        iat: Some(access_token.created_at.timestamp()),
235        sub: access_token.user_id.map(|id| id.to_string()),
236        aud: access_token.audience.clone(),
237        iss: Some(issuer),
238        jti: Some(access_token.jti.to_string()),
239        token_type: Some(match access_token.token_type {
240            TokenType::Bearer => "Bearer".to_string(),
241            TokenType::DPoP => "DPoP".to_string(),
242        }),
243    };
244
245    server_token.authorized_ok(
246        HttpResponse::Ok()
247            .insert_header(("Cache-Control", "no-store"))
248            .json(response),
249    )
250}
251
252pub fn _add_routes(cfg: &mut web::ServiceConfig) {
253    cfg.route("/introspect", web::post().to(introspect));
254}