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    let base_url = app_conf.base_url.trim_end_matches('/');
161    let issuer = format!("{}/api/v0/main-frontend/oauth", base_url);
162
163    let response = IntrospectResponse {
164        active: true,
165        scope: Some(access_token.scopes.join(" ")),
166        client_id: Some(token_client.client_id.clone()),
167        username: access_token.user_id.map(|id| id.to_string()),
168        exp: Some(access_token.expires_at.timestamp()),
169        iat: Some(access_token.created_at.timestamp()),
170        sub: access_token.user_id.map(|id| id.to_string()),
171        aud: access_token.audience.clone(),
172        iss: Some(issuer),
173        jti: Some(access_token.jti.to_string()),
174        token_type: Some(match access_token.token_type {
175            TokenType::Bearer => "Bearer".to_string(),
176            TokenType::DPoP => "DPoP".to_string(),
177        }),
178        upstream_id,
179        client_bearer_allowed,
180    };
181
182    server_token.authorized_ok(
183        HttpResponse::Ok()
184            .insert_header(("Cache-Control", "no-store"))
185            .json(response),
186    )
187}
188
189/// Authenticate the caller of the introspection endpoint.
190///
191/// Only a **confidential** client may introspect (RFC 7662 §2.1: the endpoint MUST be protected).
192/// A public client id is not a credential — `tmc-vscode`'s is hardcoded in the extension — so
193/// accepting one turned the endpoint into a validity-and-owner oracle for any leaked token,
194/// callable with no secret at all.
195///
196/// An unknown `client_id`, a public client, or a bad secret is 401 `invalid_client` (§2.3). Only
197/// the *token's* validity is reported as `200 {"active": false}`: folding failed **client**
198/// authentication into that answer makes a caller's credential typo indistinguishable from every
199/// one of its users holding an inactive token.
200async fn authenticate_introspecting_client(
201    conn: &mut sqlx::PgConnection,
202    form: &crate::domain::oauth::introspect_query::IntrospectParams,
203    token_hmac_key: &secrecy::SecretString,
204) -> Result<OAuthClient, ControllerError> {
205    let client = authenticate_oauth_client(
206        conn,
207        &form.client_id,
208        form.client_secret.as_ref(),
209        token_hmac_key,
210    )
211    .await
212    .map_err(|e| match e {
213        ClientAuthError::UnknownClient => oauth_invalid_client("invalid client_id"),
214        ClientAuthError::ClientSecretMissing => {
215            tracing::warn!("OAuth introspect: confidential client has no stored secret");
216            oauth_invalid_client("invalid client secret")
217        }
218        ClientAuthError::ClientSecretMismatch => {
219            tracing::warn!("OAuth introspect: invalid client secret");
220            oauth_invalid_client("invalid client secret")
221        }
222    })?;
223
224    if !client.is_confidential() {
225        tracing::warn!("OAuth introspect: public client may not introspect");
226        return Err(oauth_invalid_client("invalid client_id"));
227    }
228
229    Ok(client)
230}
231
232/// Resolve the token owner's legacy TMC `upstream_id` for the introspection
233/// response — a privileged, non-standard claim consumed by tmc-server.
234///
235/// Only a caller that authenticated as a **confidential** client (its secret was
236/// verified before this point) receives it; for public clients the user lookup is
237/// skipped entirely. A lookup failure omits the claim rather than failing
238/// introspection.
239async fn resolve_gated_upstream_id(
240    conn: &mut sqlx::PgConnection,
241    introspecting_client: &OAuthClient,
242    token_user_id: Option<uuid::Uuid>,
243) -> Option<i32> {
244    if !introspecting_client.is_confidential() {
245        return None;
246    }
247    match token_user_id {
248        Some(user_id) => match models::users::get_by_id(conn, user_id).await {
249            Ok(user) => user.upstream_id,
250            Err(e) => {
251                tracing::warn!(err = %e, "OAuth introspect: token user lookup failed; omitting upstream_id");
252                None
253            }
254        },
255        None => None,
256    }
257}
258
259/// Resolve the `client_bearer_allowed` member of the introspection response — a privileged,
260/// non-standard member letting resource servers apply the same `bearer_allowed = false`
261/// rejection `domain::exercise_services::token` applies here.
262///
263/// Reports the **issuing** client (`token_client`), never the introspecting caller. Gated like
264/// `upstream_id`: disclosed only to a confidential caller, and omitted rather than serialized
265/// as `false` otherwise, so a `false` is never ambiguous between "not permitted" and "not
266/// disclosed". See `IntrospectResponse::client_bearer_allowed` for the fail-closed contract.
267fn resolve_gated_bearer_allowed(
268    introspecting_client: &OAuthClient,
269    token_client: &OAuthClient,
270) -> Option<bool> {
271    if !introspecting_client.is_confidential() {
272        return None;
273    }
274    Some(token_client.allows_bearer())
275}
276
277pub fn _add_routes(cfg: &mut web::ServiceConfig) {
278    // Matches `/token`, `/authorize` and the device endpoints: without it an unmetered caller can
279    // drive a token lookup per request.
280    cfg.service(
281        web::resource("/introspect")
282            .wrap(RateLimit::new(RateLimitConfig {
283                per_minute: Some(100),
284                per_hour: Some(500),
285                per_day: Some(2000),
286                per_month: None,
287                ..Default::default()
288            }))
289            .route(web::post().to(introspect)),
290    );
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::domain::oauth::introspect_query::IntrospectParams;
297    use crate::test_helper::*;
298    use headless_lms_models::{
299        library::oauth::{GrantTypeName, generate_access_token, token_digest_sha256},
300        oauth_client::{ApplicationType, NewClientParams, OAuthClient, TokenEndpointAuthMethod},
301    };
302    use secrecy::SecretString;
303    use sqlx::PgConnection;
304    use uuid::Uuid;
305
306    fn hmac_key() -> SecretString {
307        SecretString::new("test-introspect-hmac-key".to_string().into())
308    }
309
310    async fn insert_client(
311        conn: &mut PgConnection,
312        auth_method: TokenEndpointAuthMethod,
313        bearer_allowed: bool,
314    ) -> OAuthClient {
315        let client_id = format!("cli-{}", &generate_access_token()[..12]);
316        let secret = token_digest_sha256("introspect-test-secret", &hmac_key());
317        let (client_secret, require_pkce) = match auth_method {
318            TokenEndpointAuthMethod::ClientSecretPost => (Some(&secret), false),
319            TokenEndpointAuthMethod::None => (None, true),
320        };
321        OAuthClient::insert(
322            conn,
323            NewClientParams {
324                client_id: &client_id,
325                client_name: "Introspect test client",
326                application_type: ApplicationType::Service,
327                token_endpoint_auth_method: auth_method,
328                client_secret,
329                client_secret_expires_at: None,
330                redirect_uris: &["https://example.com/callback".to_string()],
331                post_logout_redirect_uris: None,
332                allowed_grant_types: &[GrantTypeName::RefreshToken],
333                scopes: &["exercise-services".to_string()],
334                require_pkce,
335                pkce_methods_allowed: &[],
336                allowed_origins: None,
337                bearer_allowed,
338            },
339        )
340        .await
341        .unwrap()
342    }
343
344    /// A confidential caller (secret verified) receives the privileged
345    /// `upstream_id` claim.
346    #[actix_web::test]
347    async fn upstream_id_exposed_to_confidential_client() {
348        insert_data!(:tx);
349        let user = headless_lms_models::users::insert_with_upstream_id_and_moocfi_id(
350            tx.as_mut(),
351            "introspect-confidential@example.com",
352            None,
353            None,
354            424242,
355            Uuid::new_v4(),
356        )
357        .await
358        .unwrap();
359        let client =
360            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
361
362        let upstream_id = resolve_gated_upstream_id(tx.as_mut(), &client, Some(user.id)).await;
363        assert_eq!(upstream_id, Some(424242));
364    }
365
366    /// A public caller (no secret) never receives `upstream_id`, even for a token
367    /// whose owner has one.
368    #[actix_web::test]
369    async fn upstream_id_hidden_from_public_client() {
370        insert_data!(:tx);
371        let user = headless_lms_models::users::insert_with_upstream_id_and_moocfi_id(
372            tx.as_mut(),
373            "introspect-public@example.com",
374            None,
375            None,
376            515151,
377            Uuid::new_v4(),
378        )
379        .await
380        .unwrap();
381        let client = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
382
383        let upstream_id = resolve_gated_upstream_id(tx.as_mut(), &client, Some(user.id)).await;
384        assert_eq!(upstream_id, None);
385    }
386
387    /// A confidential caller learns that the token's issuing client may use Bearer
388    /// tokens, so tmc-server can accept a token this backend would also accept.
389    #[actix_web::test]
390    async fn client_bearer_allowed_reported_true_to_confidential_client() {
391        insert_data!(:tx);
392        let caller =
393            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
394        let token_client = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
395
396        assert_eq!(
397            resolve_gated_bearer_allowed(&caller, &token_client),
398            Some(true)
399        );
400    }
401
402    /// The case the member exists for: a token issued to a client barred from Bearer
403    /// use. `Some(false)` is what lets tmc-server refuse the token, matching
404    /// `domain::exercise_services::token`'s own `allows_bearer` rejection.
405    #[actix_web::test]
406    async fn client_bearer_allowed_reported_false_to_confidential_client() {
407        insert_data!(:tx);
408        let caller =
409            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
410        let token_client = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, false).await;
411
412        assert_eq!(
413            resolve_gated_bearer_allowed(&caller, &token_client),
414            Some(false)
415        );
416    }
417
418    /// A public caller is told nothing — the member is omitted, not reported as
419    /// `false`, so a `false` in the wire response is always authoritative.
420    #[actix_web::test]
421    async fn client_bearer_allowed_hidden_from_public_client() {
422        insert_data!(:tx);
423        let caller = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
424        let token_client = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
425
426        assert_eq!(resolve_gated_bearer_allowed(&caller, &token_client), None);
427    }
428
429    /// The member describes the *issuing* client, not the introspecting caller:
430    /// a caller with `bearer_allowed = true` introspecting a token from a
431    /// `bearer_allowed = false` client must see `false`, and vice versa.
432    #[actix_web::test]
433    async fn client_bearer_allowed_reflects_issuing_client_not_caller() {
434        insert_data!(:tx);
435        let permissive_caller =
436            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
437        let restricted_caller = insert_client(
438            tx.as_mut(),
439            TokenEndpointAuthMethod::ClientSecretPost,
440            false,
441        )
442        .await;
443        let permissive_token_client =
444            insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
445        let restricted_token_client =
446            insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, false).await;
447
448        assert_eq!(
449            resolve_gated_bearer_allowed(&permissive_caller, &restricted_token_client),
450            Some(false),
451            "a permissive caller must not mask the issuing client's restriction"
452        );
453        assert_eq!(
454            resolve_gated_bearer_allowed(&restricted_caller, &permissive_token_client),
455            Some(true),
456            "the caller's own bearer_allowed must not leak into the response"
457        );
458    }
459
460    fn params(client_id: &str, client_secret: Option<&str>) -> IntrospectParams {
461        IntrospectParams {
462            client_id: client_id.to_string(),
463            client_secret: client_secret.map(|s| SecretString::new(s.to_string().into())),
464            token: SecretString::new("some-token".to_string().into()),
465            token_type_hint: None,
466        }
467    }
468
469    fn assert_invalid_client(err: ControllerError) {
470        match err.error_type() {
471            ControllerErrorType::OAuthError(data) => assert_eq!(data.error, "invalid_client"),
472            other => panic!("expected OAuthError invalid_client, got {:?}", other),
473        }
474    }
475
476    #[actix_web::test]
477    async fn confidential_client_with_the_right_secret_authenticates() {
478        insert_data!(:tx);
479        let client =
480            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
481
482        let authenticated = authenticate_introspecting_client(
483            tx.as_mut(),
484            &params(&client.client_id, Some("introspect-test-secret")),
485            &hmac_key(),
486        )
487        .await
488        .expect("correct credentials must authenticate");
489        assert_eq!(authenticated.id, client.id);
490    }
491
492    /// A wrong-but-non-blank `client_id` must be a distinguishable 401 `invalid_client`, not
493    /// `200 {"active": false}` — otherwise a caller's credential typo looks exactly like every
494    /// one of its users' tokens expiring at once.
495    #[actix_web::test]
496    async fn unknown_client_id_is_invalid_client() {
497        insert_data!(:tx);
498
499        let err = authenticate_introspecting_client(
500            tx.as_mut(),
501            &params("no-such-client", Some("introspect-test-secret")),
502            &hmac_key(),
503        )
504        .await
505        .expect_err("an unknown client_id must be rejected");
506        assert_invalid_client(err);
507    }
508
509    /// Same reasoning as an unknown `client_id`: a rotated-but-not-redeployed secret is
510    /// misconfiguration on the caller's side, not a statement about the token.
511    #[actix_web::test]
512    async fn wrong_client_secret_is_invalid_client() {
513        insert_data!(:tx);
514        let client =
515            insert_client(tx.as_mut(), TokenEndpointAuthMethod::ClientSecretPost, true).await;
516
517        let err = authenticate_introspecting_client(
518            tx.as_mut(),
519            &params(&client.client_id, Some("wrong-secret")),
520            &hmac_key(),
521        )
522        .await
523        .expect_err("a wrong client secret must be rejected");
524        assert_invalid_client(err);
525
526        let missing = authenticate_introspecting_client(
527            tx.as_mut(),
528            &params(&client.client_id, None),
529            &hmac_key(),
530        )
531        .await
532        .expect_err("a confidential client must not authenticate without a secret");
533        assert_invalid_client(missing);
534    }
535
536    /// A public client id is not a credential — `tmc-vscode`'s ships inside the extension — so it
537    /// must not buy an introspection answer, with or without a secret.
538    #[actix_web::test]
539    async fn public_client_cannot_introspect() {
540        insert_data!(:tx);
541        let client = insert_client(tx.as_mut(), TokenEndpointAuthMethod::None, true).await;
542
543        let err = authenticate_introspecting_client(
544            tx.as_mut(),
545            &params(&client.client_id, None),
546            &hmac_key(),
547        )
548        .await
549        .expect_err("a public client must not be able to introspect");
550        assert_invalid_client(err);
551
552        let err = authenticate_introspecting_client(
553            tx.as_mut(),
554            &params(&client.client_id, Some("anything")),
555            &hmac_key(),
556        )
557        .await
558        .expect_err("offering a secret must not make a public client confidential");
559        assert_invalid_client(err);
560    }
561}