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}