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