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 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
187async 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
230async 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
257fn 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 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 #[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 #[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 #[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 #[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 #[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 #[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 ¶ms(&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 #[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 ¶ms("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 #[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 ¶ms(&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 ¶ms(&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 #[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 ¶ms(&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 ¶ms(&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}