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#[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 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 let token_digest = token_digest_sha256(form.token.expose_secret(), token_hmac_key);
132
133 let access_token_result = OAuthAccessToken::find_valid(&mut conn, token_digest).await;
135
136 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 tracing::Span::current().record("token_type", format!("{:?}", access_token.token_type));
151 tracing::Span::current().record("token_active", "true");
152
153 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 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
189async 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
232async 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
259fn 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 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 #[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 #[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 #[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 #[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 #[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 #[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 ¶ms(&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 #[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 ¶ms("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 #[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 ¶ms(&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 ¶ms(&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 #[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 ¶ms(&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 ¶ms(&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}