Skip to main content

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