Skip to main content

headless_lms_server/controllers/main_frontend/oauth/
introspect.rs

1use crate::domain::oauth::helpers::{
2    ClientAuthError, authenticate_oauth_client, oauth_invalid_client,
3};
4use crate::domain::oauth::introspect_query::IntrospectQuery;
5use crate::domain::oauth::introspect_response::IntrospectResponse;
6use crate::domain::oauth::oauth_validated::OAuthValidated;
7use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig};
8use crate::prelude::*;
9use actix_web::{HttpResponse, web};
10use headless_lms_base::config::ApplicationConfiguration;
11use models::{
12    library::oauth::token_digest_sha256,
13    oauth_access_token::{OAuthAccessToken, TokenType},
14    oauth_client::OAuthClient,
15};
16use secrecy::ExposeSecret;
17use sqlx::PgPool;
18use utoipa::OpenApi;
19
20#[derive(OpenApi)]
21#[openapi(paths(introspect))]
22#[allow(dead_code)]
23pub(crate) struct MainFrontendOauthIntrospectApiDoc;
24
25/// Handles the `/introspect` endpoint for OAuth 2.0 token introspection (RFC 7662).
26///
27/// This endpoint allows resource servers to query the authorization server about
28/// the active state and metadata of an access token.
29///
30/// ### Security Features
31/// - Only a confidential client may introspect; an unknown client, a public client or a bad
32///   secret is 401 `invalid_client` (RFC 7662 §2.1, §2.3)
33/// - Returns 200 with `active: false` for an invalid/expired *token* (RFC 7662 §2.1), so token
34///   existence is never disclosed to an authenticated caller
35///
36/// ### Request Parameters
37/// - `token` (required): The token to be introspected
38/// - `token_type_hint` (optional): Hint about token type ("access_token" or "refresh_token")
39/// - `client_id` (required): Client identifier of a confidential client
40/// - `client_secret` (required): Client secret
41///
42/// ### Response
43/// Returns a JSON object with:
44/// - `active` (bool, required): Whether the token is active
45/// - Additional fields only present if `active: true`:
46///   - `scope`: Space-separated list of scopes
47///   - `client_id`: Client identifier
48///   - `username`/`sub`: User identifier (if token has user)
49///   - `exp`: Expiration timestamp (Unix time)
50///   - `iat`: Issued at timestamp (Unix time)
51///   - `aud`: Audience
52///   - `iss`: Issuer
53///   - `jti`: JWT ID
54///   - `token_type`: "Bearer" or "DPoP"
55/// - Non-standard members, returned only to callers that authenticated as a
56///   confidential client and omitted (never falsified) otherwise:
57///   - `upstream_id`: the token owner's legacy TMC user id
58///   - `client_bearer_allowed`: whether the client the token was issued to may use it
59///     as a plain Bearer credential. Consumers must fail closed if it is absent.
60///
61/// Follows [RFC 7662 — OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662).
62///
63/// # Example
64/// ```http
65/// POST /api/v0/main-frontend/oauth/introspect HTTP/1.1
66/// Content-Type: application/x-www-form-urlencoded
67///
68/// token=ACCESS_TOKEN&client_id=test-client-id&client_secret=test-secret
69/// ```
70///
71/// Successful response:
72/// ```http
73/// HTTP/1.1 200 OK
74/// Content-Type: application/json
75/// Cache-Control: no-store
76///
77/// {
78///   "active": true,
79///   "scope": "openid profile email",
80///   "client_id": "test-client-id",
81///   "sub": "550e8400-e29b-41d4-a716-446655440000",
82///   "username": "550e8400-e29b-41d4-a716-446655440000",
83///   "exp": 1735689600,
84///   "iat": 1735686000,
85///   "iss": "https://example.com/api/v0/main-frontend/oauth",
86///   "jti": "123e4567-e89b-12d3-a456-426614174000",
87///   "token_type": "Bearer"
88/// }
89/// ```
90///
91/// Inactive token response:
92/// ```http
93/// HTTP/1.1 200 OK
94/// Content-Type: application/json
95/// Cache-Control: no-store
96///
97/// {
98///   "active": false
99/// }
100/// ```
101#[instrument(skip(pool, app_conf, form))]
102#[utoipa::path(
103    post,
104    path = "/introspect",
105    operation_id = "introspectOauthToken",
106    tag = "oauth",
107    request_body(
108        content = serde_json::Value,
109        content_type = "application/x-www-form-urlencoded"
110    ),
111    responses(
112        (status = 200, description = "OAuth token introspection response", body = serde_json::Value),
113        (status = 401, description = "Client authentication failed (invalid_client)")
114    )
115)]
116pub async fn introspect(
117    pool: web::Data<PgPool>,
118    OAuthValidated(form): OAuthValidated<IntrospectQuery>,
119    app_conf: web::Data<ApplicationConfiguration>,
120) -> ControllerResult<HttpResponse> {
121    let mut conn = pool.acquire().await?;
122    let server_token = skip_authorize();
123
124    // Add non-secret fields to the span for observability
125    tracing::Span::current().record("client_id", &form.client_id);
126
127    let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
128    let client = authenticate_introspecting_client(&mut conn, &form, token_hmac_key).await?;
129
130    // Hash the provided token to get digest
131    let token_digest = token_digest_sha256(form.token.expose_secret(), token_hmac_key);
132
133    // Look up the access token (only access tokens are supported)
134    let access_token_result = OAuthAccessToken::find_valid(&mut conn, token_digest).await;
135
136    // If token not found or expired, return active: false
137    let access_token = match access_token_result {
138        Ok(token) => token,
139        Err(e) => {
140            tracing::debug!(err = %e, "OAuth introspect: access token lookup failed (inactive/expired token)");
141            return server_token.authorized_ok(
142                HttpResponse::Ok()
143                    .insert_header(("Cache-Control", "no-store"))
144                    .json(IntrospectResponse::inactive()),
145            );
146        }
147    };
148
149    // Add token type to span for observability
150    tracing::Span::current().record("token_type", format!("{:?}", access_token.token_type));
151    tracing::Span::current().record("token_active", "true");
152
153    // Fetch the client that originally issued the token (not the introspecting client)
154    let token_client = OAuthClient::find_by_id(&mut conn, access_token.client_id).await?;
155
156    let upstream_id = resolve_gated_upstream_id(&mut conn, &client, access_token.user_id).await;
157    let client_bearer_allowed = resolve_gated_bearer_allowed(&client, &token_client);
158
159    // Build response with token metadata
160
161    let response = IntrospectResponse {
162        active: true,
163        scope: Some(access_token.scopes.join(" ")),
164        client_id: Some(token_client.client_id.clone()),
165        username: access_token.user_id.map(|id| id.to_string()),
166        exp: Some(access_token.expires_at.timestamp()),
167        iat: Some(access_token.created_at.timestamp()),
168        sub: access_token.user_id.map(|id| id.to_string()),
169        aud: access_token.audience.clone(),
170        iss: Some(app_conf.oauth_issuer()),
171        jti: Some(access_token.jti.to_string()),
172        token_type: Some(match access_token.token_type {
173            TokenType::Bearer => "Bearer".to_string(),
174            TokenType::DPoP => "DPoP".to_string(),
175        }),
176        upstream_id,
177        client_bearer_allowed,
178    };
179
180    server_token.authorized_ok(
181        HttpResponse::Ok()
182            .insert_header(("Cache-Control", "no-store"))
183            .json(response),
184    )
185}
186
187/// Authenticate the caller of the introspection endpoint.
188///
189/// Only a **confidential** client may introspect (RFC 7662 §2.1: the endpoint MUST be protected).
190/// A public client id is not a credential — `tmc-vscode`'s is hardcoded in the extension — so
191/// accepting one turned the endpoint into a validity-and-owner oracle for any leaked token,
192/// callable with no secret at all.
193///
194/// An unknown `client_id`, a public client, or a bad secret is 401 `invalid_client` (§2.3). Only
195/// the *token's* validity is reported as `200 {"active": false}`: folding failed **client**
196/// authentication into that answer makes a caller's credential typo indistinguishable from every
197/// one of its users holding an inactive token.
198async fn authenticate_introspecting_client(
199    conn: &mut sqlx::PgConnection,
200    form: &crate::domain::oauth::introspect_query::IntrospectParams,
201    token_hmac_key: &secrecy::SecretString,
202) -> Result<OAuthClient, ControllerError> {
203    let client = authenticate_oauth_client(
204        conn,
205        &form.client_id,
206        form.client_secret.as_ref(),
207        token_hmac_key,
208    )
209    .await
210    .map_err(|e| match e {
211        ClientAuthError::UnknownClient => oauth_invalid_client("invalid client_id"),
212        ClientAuthError::ClientSecretMissing => {
213            tracing::warn!("OAuth introspect: confidential client has no stored secret");
214            oauth_invalid_client("invalid client secret")
215        }
216        ClientAuthError::ClientSecretMismatch => {
217            tracing::warn!("OAuth introspect: invalid client secret");
218            oauth_invalid_client("invalid client secret")
219        }
220    })?;
221
222    if !client.is_confidential() {
223        tracing::warn!("OAuth introspect: public client may not introspect");
224        return Err(oauth_invalid_client("invalid client_id"));
225    }
226
227    Ok(client)
228}
229
230/// Resolve the token owner's legacy TMC `upstream_id` for the introspection
231/// response — a privileged, non-standard claim consumed by tmc-server.
232///
233/// Only a caller that authenticated as a **confidential** client (its secret was
234/// verified before this point) receives it; for public clients the user lookup is
235/// skipped entirely. A lookup failure omits the claim rather than failing
236/// introspection.
237async fn resolve_gated_upstream_id(
238    conn: &mut sqlx::PgConnection,
239    introspecting_client: &OAuthClient,
240    token_user_id: Option<uuid::Uuid>,
241) -> Option<i32> {
242    if !introspecting_client.is_confidential() {
243        return None;
244    }
245    match token_user_id {
246        Some(user_id) => match models::users::get_by_id(conn, user_id).await {
247            Ok(user) => user.upstream_id,
248            Err(e) => {
249                tracing::warn!(err = %e, "OAuth introspect: token user lookup failed; omitting upstream_id");
250                None
251            }
252        },
253        None => None,
254    }
255}
256
257/// Resolve the `client_bearer_allowed` member of the introspection response — a privileged,
258/// non-standard member letting resource servers apply the same `bearer_allowed = false`
259/// rejection `domain::exercise_services::token` applies here.
260///
261/// Reports the **issuing** client (`token_client`), never the introspecting caller. Gated like
262/// `upstream_id`: disclosed only to a confidential caller, and omitted rather than serialized
263/// as `false` otherwise, so a `false` is never ambiguous between "not permitted" and "not
264/// disclosed". See `IntrospectResponse::client_bearer_allowed` for the fail-closed contract.
265fn resolve_gated_bearer_allowed(
266    introspecting_client: &OAuthClient,
267    token_client: &OAuthClient,
268) -> Option<bool> {
269    if !introspecting_client.is_confidential() {
270        return None;
271    }
272    Some(token_client.allows_bearer())
273}
274
275pub fn _add_routes(cfg: &mut web::ServiceConfig) {
276    // Matches `/token`, `/authorize` and the device endpoints: without it an unmetered caller can
277    // drive a token lookup per request.
278    cfg.service(
279        web::resource("/introspect")
280            .wrap(
281                RateLimit::new(RateLimitConfig {
282                    per_minute: Some(100),
283                    per_hour: Some(500),
284                    per_day: Some(2000),
285                    per_month: None,
286                    ..Default::default()
287                })
288                .bypassable(),
289            )
290            .route(web::post().to(introspect)),
291    );
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::domain::oauth::introspect_query::IntrospectParams;
298    use crate::test_helper::*;
299    use headless_lms_models::{
300        library::oauth::{GrantTypeName, generate_access_token, token_digest_sha256},
301        oauth_client::{ApplicationType, NewClientParams, OAuthClient, TokenEndpointAuthMethod},
302    };
303    use secrecy::SecretString;
304    use sqlx::PgConnection;
305    use uuid::Uuid;
306
307    fn hmac_key() -> SecretString {
308        SecretString::new("test-introspect-hmac-key".to_string().into())
309    }
310
311    async fn insert_client(
312        conn: &mut PgConnection,
313        auth_method: TokenEndpointAuthMethod,
314        bearer_allowed: bool,
315    ) -> OAuthClient {
316        let client_id = format!("cli-{}", &generate_access_token()[..12]);
317        let secret = token_digest_sha256("introspect-test-secret", &hmac_key());
318        let (client_secret, require_pkce) = match auth_method {
319            TokenEndpointAuthMethod::ClientSecretPost => (Some(&secret), false),
320            TokenEndpointAuthMethod::None => (None, true),
321        };
322        OAuthClient::insert(
323            conn,
324            NewClientParams {
325                client_id: &client_id,
326                client_name: "Introspect test client",
327                application_type: ApplicationType::Service,
328                token_endpoint_auth_method: auth_method,
329                client_secret,
330                client_secret_expires_at: None,
331                redirect_uris: &["https://example.com/callback".to_string()],
332                post_logout_redirect_uris: None,
333                allowed_grant_types: &[GrantTypeName::RefreshToken],
334                scopes: &["exercise-services".to_string()],
335                require_pkce,
336                pkce_methods_allowed: &[],
337                allowed_origins: None,
338                bearer_allowed,
339            },
340        )
341        .await
342        .unwrap()
343    }
344
345    /// A confidential caller (secret verified) receives the privileged
346    /// `upstream_id` claim.
347    #[actix_web::test]
348    async fn upstream_id_exposed_to_confidential_client() {
349        insert_data!(:tx);
350        let user = headless_lms_models::users::insert_with_upstream_id_and_moocfi_id(
351            tx.as_mut(),
352            "introspect-confidential@example.com",
353            None,
354            None,
355            424242,
356            Uuid::new_v4(),
357        )
358        .await
359        .unwrap();
360        let client =
361            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
362
363        let upstream_id = resolve_gated_upstream_id(tx.as_mut(), &client, Some(user.id)).await;
364        assert_eq!(upstream_id, Some(424242));
365    }
366
367    /// A public caller (no secret) never receives `upstream_id`, even for a token
368    /// whose owner has one.
369    #[actix_web::test]
370    async fn upstream_id_hidden_from_public_client() {
371        insert_data!(:tx);
372        let user = headless_lms_models::users::insert_with_upstream_id_and_moocfi_id(
373            tx.as_mut(),
374            "introspect-public@example.com",
375            None,
376            None,
377            515151,
378            Uuid::new_v4(),
379        )
380        .await
381        .unwrap();
382        let client = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
383
384        let upstream_id = resolve_gated_upstream_id(tx.as_mut(), &client, Some(user.id)).await;
385        assert_eq!(upstream_id, None);
386    }
387
388    /// A confidential caller learns that the token's issuing client may use Bearer
389    /// tokens, so tmc-server can accept a token this backend would also accept.
390    #[actix_web::test]
391    async fn client_bearer_allowed_reported_true_to_confidential_client() {
392        insert_data!(:tx);
393        let caller =
394            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
395        let token_client = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
396
397        assert_eq!(
398            resolve_gated_bearer_allowed(&caller, &token_client),
399            Some(true)
400        );
401    }
402
403    /// The case the member exists for: a token issued to a client barred from Bearer
404    /// use. `Some(false)` is what lets tmc-server refuse the token, matching
405    /// `domain::exercise_services::token`'s own `allows_bearer` rejection.
406    #[actix_web::test]
407    async fn client_bearer_allowed_reported_false_to_confidential_client() {
408        insert_data!(:tx);
409        let caller =
410            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
411        let token_client = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, false).await;
412
413        assert_eq!(
414            resolve_gated_bearer_allowed(&caller, &token_client),
415            Some(false)
416        );
417    }
418
419    /// A public caller is told nothing — the member is omitted, not reported as
420    /// `false`, so a `false` in the wire response is always authoritative.
421    #[actix_web::test]
422    async fn client_bearer_allowed_hidden_from_public_client() {
423        insert_data!(:tx);
424        let caller = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
425        let token_client = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
426
427        assert_eq!(resolve_gated_bearer_allowed(&caller, &token_client), None);
428    }
429
430    /// The member describes the *issuing* client, not the introspecting caller:
431    /// a caller with `bearer_allowed = true` introspecting a token from a
432    /// `bearer_allowed = false` client must see `false`, and vice versa.
433    #[actix_web::test]
434    async fn client_bearer_allowed_reflects_issuing_client_not_caller() {
435        insert_data!(:tx);
436        let permissive_caller =
437            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
438        let restricted_caller = insert_client(
439            tx.as_mut(),
440            TokenEndpointAuthMethod::ClientSecretPost,
441            false,
442        )
443        .await;
444        let permissive_token_client =
445            insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
446        let restricted_token_client =
447            insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, false).await;
448
449        assert_eq!(
450            resolve_gated_bearer_allowed(&permissive_caller, &restricted_token_client),
451            Some(false),
452            "a permissive caller must not mask the issuing client's restriction"
453        );
454        assert_eq!(
455            resolve_gated_bearer_allowed(&restricted_caller, &permissive_token_client),
456            Some(true),
457            "the caller's own bearer_allowed must not leak into the response"
458        );
459    }
460
461    fn params(client_id: &str, client_secret: Option<&str>) -> IntrospectParams {
462        IntrospectParams {
463            client_id: client_id.to_string(),
464            client_secret: client_secret.map(|s| SecretString::new(s.to_string().into())),
465            token: SecretString::new("some-token".to_string().into()),
466            token_type_hint: None,
467        }
468    }
469
470    fn assert_invalid_client(err: ControllerError) {
471        match err.error_type() {
472            ControllerErrorType::OAuthError(data) => assert_eq!(data.error, "invalid_client"),
473            other => panic!("expected OAuthError invalid_client, got {:?}", other),
474        }
475    }
476
477    #[actix_web::test]
478    async fn confidential_client_with_the_right_secret_authenticates() {
479        insert_data!(:tx);
480        let client =
481            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
482
483        let authenticated = authenticate_introspecting_client(
484            tx.as_mut(),
485            &params(&client.client_id, Some("introspect-test-secret")),
486            &hmac_key(),
487        )
488        .await
489        .expect("correct credentials must authenticate");
490        assert_eq!(authenticated.id, client.id);
491    }
492
493    /// A wrong-but-non-blank `client_id` must be a distinguishable 401 `invalid_client`, not
494    /// `200 {"active": false}` — otherwise a caller's credential typo looks exactly like every
495    /// one of its users' tokens expiring at once.
496    #[actix_web::test]
497    async fn unknown_client_id_is_invalid_client() {
498        insert_data!(:tx);
499
500        let err = authenticate_introspecting_client(
501            tx.as_mut(),
502            &params("no-such-client", Some("introspect-test-secret")),
503            &hmac_key(),
504        )
505        .await
506        .expect_err("an unknown client_id must be rejected");
507        assert_invalid_client(err);
508    }
509
510    /// Same reasoning as an unknown `client_id`: a rotated-but-not-redeployed secret is
511    /// misconfiguration on the caller's side, not a statement about the token.
512    #[actix_web::test]
513    async fn wrong_client_secret_is_invalid_client() {
514        insert_data!(:tx);
515        let client =
516            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
517
518        let err = authenticate_introspecting_client(
519            tx.as_mut(),
520            &params(&client.client_id, Some("wrong-secret")),
521            &hmac_key(),
522        )
523        .await
524        .expect_err("a wrong client secret must be rejected");
525        assert_invalid_client(err);
526
527        let missing = authenticate_introspecting_client(
528            tx.as_mut(),
529            &params(&client.client_id, None),
530            &hmac_key(),
531        )
532        .await
533        .expect_err("a confidential client must not authenticate without a secret");
534        assert_invalid_client(missing);
535    }
536
537    /// A public client id is not a credential — `tmc-vscode`'s ships inside the extension — so it
538    /// must not buy an introspection answer, with or without a secret.
539    #[actix_web::test]
540    async fn public_client_cannot_introspect() {
541        insert_data!(:tx);
542        let client = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
543
544        let err = authenticate_introspecting_client(
545            tx.as_mut(),
546            &params(&client.client_id, None),
547            &hmac_key(),
548        )
549        .await
550        .expect_err("a public client must not be able to introspect");
551        assert_invalid_client(err);
552
553        let err = authenticate_introspecting_client(
554            tx.as_mut(),
555            &params(&client.client_id, Some("anything")),
556            &hmac_key(),
557        )
558        .await
559        .expect_err("offering a secret must not make a public client confidential");
560        assert_invalid_client(err);
561    }
562}