tests.rs 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640
  1. //! `test(introspect)` suite: active shape, inactive misses, 401, ownership.
  2. //!
  3. //! Each test runs against the shared compose Redis (a per-test key-prefix
  4. //! pair isolates parallel runs) plus a migrated Postgres database on the shared
  5. //! test server, through the public [`super`] API and a one-route router for the
  6. //! HTTP status assertions.
  7. use std::sync::{
  8. Arc,
  9. atomic::{AtomicU64, Ordering},
  10. };
  11. use axum::{
  12. Router,
  13. body::Body,
  14. http::{Request, StatusCode, header},
  15. routing::post,
  16. };
  17. use base64::Engine as _;
  18. use http_body_util::BodyExt as _;
  19. use secrecy::SecretString;
  20. use serde_json::{Value, json};
  21. use sqlx::PgPool;
  22. use time::OffsetDateTime;
  23. use tower::ServiceExt as _;
  24. use uuid::Uuid;
  25. use super::{
  26. IntrospectContext, IntrospectHttpInputs, IntrospectStores, IntrospectionResponse, handler,
  27. introspect, record_act,
  28. };
  29. use crate::{
  30. config::Config,
  31. crypto::secret::{Argon2Params, hash_secret},
  32. db::{
  33. NewClient, TokenEndpointAuthMethod, client_repo, resource_repo,
  34. test_support::migrated_pool,
  35. user_repo::{self, NewUser, UserRecord},
  36. },
  37. jose::jwt_access::{JwtAccessSigner, JwtClaims},
  38. protocol::{
  39. client_auth::ClientAuthRequest,
  40. revoke::{RevokeContext, RevokeOutcome, RevokeRequest, RevokeStores, revoke_tokens},
  41. token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService},
  42. },
  43. state::{AppState, Clock, RedisConn},
  44. store::Store,
  45. test_guard::Lease,
  46. };
  47. /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
  48. type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
  49. static NONCE: AtomicU64 = AtomicU64::new(1);
  50. fn unique(label: &str) -> String {
  51. let n = NONCE.fetch_add(1, Ordering::Relaxed);
  52. format!("introspect-test-{n}-{label}")
  53. }
  54. fn now_unix() -> i64 {
  55. OffsetDateTime::now_utc().unix_timestamp()
  56. }
  57. /// Live handles for one test: migrated Postgres, Redis, store, and config.
  58. struct Harness {
  59. _db: Lease<crate::db::test_support::Db>,
  60. pool: PgPool,
  61. conn: RedisConn,
  62. store: Store,
  63. config: Arc<Config>,
  64. }
  65. async fn harness() -> TestResult<Harness> {
  66. let (db, pool) = migrated_pool().await?;
  67. let redis_url = crate::test_support::test_redis_url();
  68. let manager = crate::test_support::redis_manager().await?;
  69. let (token_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
  70. let store = Store::from_parts(
  71. token_prefix.clone(),
  72. ratelimit_prefix.clone(),
  73. crate::config::DurabilityMode::WaitAof,
  74. );
  75. let fixture = json!({
  76. "issuer": "https://auth.example.test",
  77. "environment": "development",
  78. "tls": {"cert_path": "/tmp/introspect-test-cert.pem", "key_path": "/tmp/introspect-test-key.pem"},
  79. "database": {"url": "postgres://127.0.0.1:1/introspect"},
  80. "redis": {
  81. "url": redis_url,
  82. "durability": "waitaof",
  83. "token_key_prefix": token_prefix,
  84. "ratelimit_key_prefix": ratelimit_prefix,
  85. },
  86. "token_pepper": "introspect-test-pepper-0123456789abcdef",
  87. "totp_encryption_key": "introspect-test-totp-key-0123456789abcdef",
  88. "pii_encryption_key": "introspect-test-pii-key-0123456789abcdef",
  89. "resource_indicators_enabled": true,
  90. "jwt_access_tokens_enabled": true,
  91. "jwt_signing_key": "bcdefghijklmnopqrstuvwxyzabcdefg",
  92. });
  93. let source = ::config::Config::try_from(&fixture)?;
  94. let config = Config::from_source(source)?;
  95. Ok(Harness {
  96. _db: db,
  97. pool,
  98. conn: RedisConn::Single(manager),
  99. store,
  100. config: Arc::new(config),
  101. })
  102. }
  103. async fn insert_client(
  104. pool: &PgPool,
  105. keys: &crate::keys::KeyRing,
  106. client_id: &str,
  107. method: TokenEndpointAuthMethod,
  108. secret: Option<&str>,
  109. ) -> TestResult {
  110. let params = Argon2Params::new(8, 1, 1, 32).map_err(|error| format!("{error:?}"))?;
  111. let secret_hash = secret
  112. .map(|value| hash_secret(value, &params))
  113. .transpose()?;
  114. let new = NewClient {
  115. id: Uuid::new_v4(),
  116. client_id: client_id.to_owned(),
  117. client_secret_hash: secret_hash,
  118. client_secret_expires_at: None,
  119. client_name: format!("{client_id} name"),
  120. grant_types: vec!["client_credentials".to_owned()],
  121. response_types: Vec::new(),
  122. scope: "orders:read".to_owned(),
  123. token_endpoint_auth_method: method.as_str().to_owned(),
  124. jwks: None,
  125. jwks_uri: None,
  126. tls_client_auth_subject_dn: None,
  127. tls_client_auth_cert_thumbprint: None,
  128. request_uris: None,
  129. contacts: None,
  130. software_id: None,
  131. dpop_bound_access_tokens: false,
  132. access_token_format: "opaque".to_owned(),
  133. registration_access_token_hash: None,
  134. };
  135. client_repo::insert(pool, keys, false, &new).await?;
  136. Ok(())
  137. }
  138. fn token_service(h: &Harness) -> TestResult<TokenService> {
  139. Ok(TokenService::new(&h.config.ttl, &h.config.keys)?)
  140. }
  141. /// Mint one token pair owned by `client_id` with an explicit sender proof.
  142. async fn mint_bound(
  143. h: &mut Harness,
  144. client_id: &str,
  145. proof: SenderProof,
  146. ) -> TestResult<crate::protocol::token::MintedTokens> {
  147. let service = token_service(h)?;
  148. Ok(
  149. service
  150. .mint(
  151. &mut h.conn,
  152. &h.store,
  153. &MintRequest {
  154. client_id: client_id.to_owned(),
  155. client_kind: ClientKind::Confidential,
  156. dpop_required: false,
  157. subject: "user-1".to_owned(),
  158. scope: "orders:read".to_owned(),
  159. access_scope: "orders:read".to_owned(),
  160. grant_id: unique("grant"),
  161. proof,
  162. aud: Vec::new(),
  163. refresh_aud: Vec::new(),
  164. format: TokenFormat::Opaque,
  165. },
  166. now_unix(),
  167. )
  168. .await?,
  169. )
  170. }
  171. /// Mint one sender-constrained access token owned by `client_id`.
  172. async fn mint_constrained(
  173. h: &mut Harness,
  174. client_id: &str,
  175. ) -> TestResult<crate::protocol::token::MintedTokens> {
  176. mint_bound(
  177. h,
  178. client_id,
  179. SenderProof {
  180. dpop_jkt: Some("jkt-thumbnail".to_owned()),
  181. mtls_x5t_s256: Some("x5t-thumbnail".to_owned()),
  182. },
  183. )
  184. .await
  185. }
  186. /// Drive the core with `client_secret_post` credentials.
  187. async fn run_core(
  188. h: &mut Harness,
  189. token: &str,
  190. hint: Option<&str>,
  191. client_id: &str,
  192. secret: &str,
  193. ) -> TestResult<IntrospectionResponse> {
  194. run_core_with_resources(h, token, hint, client_id, secret, &[]).await
  195. }
  196. /// Drive the core with an explicit validated `resource` set, mirroring the
  197. /// handler: the JWT signer is built from the harness config (never cached, so
  198. /// rotation takes effect).
  199. async fn run_core_with_resources(
  200. h: &mut Harness,
  201. token: &str,
  202. hint: Option<&str>,
  203. client_id: &str,
  204. secret: &str,
  205. resource: &[String],
  206. ) -> TestResult<IntrospectionResponse> {
  207. run_core_full(h, token, hint, client_id, secret, resource, true).await
  208. }
  209. /// Core driver with an explicit JWT-signer switch: `with_signer = false`
  210. /// reproduces the feature-flag-off handler (signer absent, so a JWT-shaped
  211. /// token introspects inactive).
  212. async fn run_core_full(
  213. h: &mut Harness,
  214. token: &str,
  215. hint: Option<&str>,
  216. client_id: &str,
  217. secret: &str,
  218. resource: &[String],
  219. with_signer: bool,
  220. ) -> TestResult<IntrospectionResponse> {
  221. let service = token_service(h)?;
  222. let signer = with_signer
  223. .then(|| JwtAccessSigner::from_config(&h.config))
  224. .transpose()?;
  225. let mut stores = IntrospectStores {
  226. pool: &h.pool,
  227. keys: &h.config.keys,
  228. conn: &mut h.conn,
  229. store: &h.store,
  230. };
  231. let ctx = IntrospectContext {
  232. issuer: &h.config.issuer,
  233. now_unix: now_unix(),
  234. resource_indicators_enabled: true,
  235. cimd: None,
  236. service: &service,
  237. jwt_signer: signer.as_ref(),
  238. };
  239. let inputs = IntrospectHttpInputs {
  240. token,
  241. token_type_hint: hint,
  242. client_id: Some(client_id),
  243. basic_header: None,
  244. body_secret: Some(SecretString::from(secret)),
  245. assertion_type: None,
  246. assertion: None,
  247. peer_certs: None,
  248. resource,
  249. };
  250. Ok(introspect(&mut stores, &ctx, &inputs).await?)
  251. }
  252. fn app_state(h: &Harness) -> AppState {
  253. AppState {
  254. pool: h.pool.clone(),
  255. redis: h.conn.clone(),
  256. config: Arc::clone(&h.config),
  257. keys: Arc::new(h.config.keys.clone()),
  258. transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
  259. clock: Clock::system(),
  260. }
  261. }
  262. fn basic_header(client_id: &str, secret: &str) -> String {
  263. let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{client_id}:{secret}"));
  264. format!("Basic {encoded}")
  265. }
  266. async fn post_form_bytes(
  267. app: Router,
  268. auth: Option<&str>,
  269. body: &str,
  270. ) -> TestResult<(StatusCode, Vec<u8>)> {
  271. let mut builder = Request::builder()
  272. .method("POST")
  273. .uri("/introspect")
  274. .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
  275. if let Some(value) = auth {
  276. builder = builder.header(header::AUTHORIZATION, value);
  277. }
  278. let request = builder.body(Body::from(body.to_owned()))?;
  279. let response = app.oneshot(request).await?;
  280. let status = response.status();
  281. let bytes = response.into_body().collect().await?.to_bytes();
  282. Ok((status, bytes.to_vec()))
  283. }
  284. async fn post_form(app: Router, auth: Option<&str>, body: &str) -> TestResult<(StatusCode, Value)> {
  285. let (status, bytes) = post_form_bytes(app, auth, body).await?;
  286. Ok((status, serde_json::from_slice(&bytes)?))
  287. }
  288. fn router(h: &Harness) -> Router {
  289. Router::new()
  290. .route("/introspect", post(handler))
  291. .with_state(app_state(h))
  292. }
  293. #[tokio::test]
  294. async fn test_introspect_active_access_token_reports_shape() -> TestResult {
  295. let mut h = harness().await?;
  296. let client_id = unique("owner");
  297. insert_client(
  298. &h.pool,
  299. &h.config.keys,
  300. &client_id,
  301. TokenEndpointAuthMethod::ClientSecretPost,
  302. Some("secret-a"),
  303. )
  304. .await?;
  305. let minted = mint_constrained(&mut h, &client_id).await?;
  306. record_act(
  307. &mut h.conn,
  308. &h.store,
  309. &minted.access_hash,
  310. &json!({"sub": "actor-1"}),
  311. h.config.ttl.access,
  312. )
  313. .await?;
  314. let before = now_unix();
  315. let response = run_core(
  316. &mut h,
  317. &minted.access_token,
  318. Some("access_token"),
  319. &client_id,
  320. "secret-a",
  321. )
  322. .await?;
  323. assert!(response.active, "minted token must be active");
  324. assert_eq!(response.scope.as_deref(), Some("orders:read"));
  325. assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
  326. assert_eq!(response.token_type.as_deref(), Some("DPoP"));
  327. assert_eq!(response.sub.as_deref(), Some("user-1"));
  328. let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
  329. assert_eq!(cnf.jkt.as_deref(), Some("jkt-thumbnail"));
  330. assert_eq!(cnf.x5t_s256.as_deref(), Some("x5t-thumbnail"));
  331. assert_eq!(response.act, Some(json!({"sub": "actor-1"})));
  332. let iat = response.iat.ok_or("iat missing")?;
  333. let exp = response.exp.ok_or("exp missing")?;
  334. assert!(iat < exp, "iat must precede exp");
  335. assert!(exp > before, "exp must be in the future");
  336. let ttl = i64::try_from(h.config.ttl.access.as_secs())?;
  337. assert_eq!(exp - iat, ttl, "window must equal the access TTL");
  338. Ok(())
  339. }
  340. #[tokio::test]
  341. async fn test_introspect_unknown_token_is_inactive_200() -> TestResult {
  342. let h = harness().await?;
  343. let client_id = unique("unknown");
  344. insert_client(
  345. &h.pool,
  346. &h.config.keys,
  347. &client_id,
  348. TokenEndpointAuthMethod::ClientSecretBasic,
  349. Some("secret-u"),
  350. )
  351. .await?;
  352. let auth = basic_header(&client_id, "secret-u");
  353. let (status, body) = post_form(
  354. router(&h),
  355. Some(&auth),
  356. "token=not-a-real-token&token_type_hint=access_token",
  357. )
  358. .await?;
  359. assert_eq!(status, StatusCode::OK, "unknown token must not 4xx");
  360. assert_eq!(body, json!({"active": false}), "no claims may leak");
  361. Ok(())
  362. }
  363. #[tokio::test]
  364. async fn test_introspect_unauthenticated_is_401() -> TestResult {
  365. let h = harness().await?;
  366. let (status, body) = post_form(router(&h), None, "token=whatever").await?;
  367. assert_eq!(status, StatusCode::UNAUTHORIZED);
  368. assert_eq!(
  369. body.get("error").and_then(Value::as_str),
  370. Some("invalid_client")
  371. );
  372. Ok(())
  373. }
  374. /// Authentication precedes catalog validation: an unauthenticated caller
  375. /// presenting an unknown `resource` observes `invalid_client` 401, never
  376. /// `invalid_target`.
  377. #[tokio::test]
  378. async fn test_introspect_unauthenticated_with_unknown_resource_is_401() -> TestResult {
  379. let h = harness().await?;
  380. let (status, body) = post_form(
  381. router(&h),
  382. None,
  383. "token=whatever&resource=https://ghost.introspect.test/",
  384. )
  385. .await?;
  386. assert_eq!(status, StatusCode::UNAUTHORIZED);
  387. assert_eq!(
  388. body.get("error").and_then(Value::as_str),
  389. Some("invalid_client"),
  390. "auth must run before the catalog check"
  391. );
  392. Ok(())
  393. }
  394. #[tokio::test]
  395. async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
  396. let mut h = harness().await?;
  397. let owner = unique("owner");
  398. insert_client(
  399. &h.pool,
  400. &h.config.keys,
  401. &owner,
  402. TokenEndpointAuthMethod::ClientSecretBasic,
  403. Some("owner-secret"),
  404. )
  405. .await?;
  406. let foreign = unique("foreign");
  407. insert_client(
  408. &h.pool,
  409. &h.config.keys,
  410. &foreign,
  411. TokenEndpointAuthMethod::ClientSecretBasic,
  412. Some("foreign-secret"),
  413. )
  414. .await?;
  415. let minted = mint_constrained(&mut h, &owner).await?;
  416. let auth = basic_header(&foreign, "foreign-secret");
  417. let (status, body) = post_form(
  418. router(&h),
  419. Some(&auth),
  420. &format!("token={}", minted.access_token),
  421. )
  422. .await?;
  423. assert_eq!(status, StatusCode::OK);
  424. assert_ne!(status, StatusCode::FORBIDDEN, "existence must not leak");
  425. assert_eq!(body, json!({"active": false}));
  426. Ok(())
  427. }
  428. #[tokio::test]
  429. async fn test_introspect_revoked_token_is_inactive() -> TestResult {
  430. let mut h = harness().await?;
  431. let client_id = unique("revoked");
  432. insert_client(
  433. &h.pool,
  434. &h.config.keys,
  435. &client_id,
  436. TokenEndpointAuthMethod::ClientSecretPost,
  437. Some("secret-r"),
  438. )
  439. .await?;
  440. let minted = mint_constrained(&mut h, &client_id).await?;
  441. let service = token_service(&h)?;
  442. let revoked = service
  443. .revoke_token(&mut h.conn, &h.store, &minted.access_token)
  444. .await?;
  445. assert!(revoked, "revoke must delete a live token");
  446. let response = run_core(
  447. &mut h,
  448. &minted.access_token,
  449. Some("access_token"),
  450. &client_id,
  451. "secret-r",
  452. )
  453. .await?;
  454. assert!(!response.active, "revoked token must be inactive");
  455. assert_eq!(response, IntrospectionResponse::inactive());
  456. Ok(())
  457. }
  458. #[tokio::test]
  459. async fn test_introspect_missing_token_form_is_400_invalid_request() -> TestResult {
  460. let h = harness().await?;
  461. let client_id = unique("missing-token");
  462. insert_client(
  463. &h.pool,
  464. &h.config.keys,
  465. &client_id,
  466. TokenEndpointAuthMethod::ClientSecretBasic,
  467. Some("secret-mt"),
  468. )
  469. .await?;
  470. let auth = basic_header(&client_id, "secret-mt");
  471. let (status, bytes) = post_form_bytes(
  472. router(&h),
  473. Some(&auth),
  474. "token_type_hint=access_token&client_secret=secret-mt",
  475. )
  476. .await?;
  477. // RFC 7662 §2.1: `token` is REQUIRED; §2.3: a malformed request is
  478. // `invalid_request` (HTTP 400), never a silent inactive 200.
  479. assert_eq!(
  480. status,
  481. StatusCode::BAD_REQUEST,
  482. "a missing token parameter must be invalid_request 400"
  483. );
  484. let body: Value = serde_json::from_slice(&bytes)?;
  485. assert_eq!(
  486. body.get("error").and_then(Value::as_str),
  487. Some("invalid_request")
  488. );
  489. Ok(())
  490. }
  491. #[tokio::test]
  492. async fn test_introspect_empty_token_is_400_invalid_request() -> TestResult {
  493. let h = harness().await?;
  494. let client_id = unique("empty-token");
  495. insert_client(
  496. &h.pool,
  497. &h.config.keys,
  498. &client_id,
  499. TokenEndpointAuthMethod::ClientSecretBasic,
  500. Some("secret-et"),
  501. )
  502. .await?;
  503. let auth = basic_header(&client_id, "secret-et");
  504. let (status, bytes) = post_form_bytes(
  505. router(&h),
  506. Some(&auth),
  507. "token=&token_type_hint=access_token",
  508. )
  509. .await?;
  510. // RFC 7662 §2.1: an empty `token` is no token at all, so §2.3's
  511. // `invalid_request` applies rather than the inactive response.
  512. assert_eq!(
  513. status,
  514. StatusCode::BAD_REQUEST,
  515. "an empty token must be invalid_request 400"
  516. );
  517. let body: Value = serde_json::from_slice(&bytes)?;
  518. assert_eq!(
  519. body.get("error").and_then(Value::as_str),
  520. Some("invalid_request")
  521. );
  522. Ok(())
  523. }
  524. #[tokio::test]
  525. async fn test_introspect_refresh_token_reports_active_shape() -> TestResult {
  526. let mut h = harness().await?;
  527. let client_id = unique("refresh-shape");
  528. insert_client(
  529. &h.pool,
  530. &h.config.keys,
  531. &client_id,
  532. TokenEndpointAuthMethod::ClientSecretPost,
  533. Some("secret-rs"),
  534. )
  535. .await?;
  536. let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
  537. let before = now_unix();
  538. let response = run_core(
  539. &mut h,
  540. &minted.refresh_token,
  541. Some("refresh_token"),
  542. &client_id,
  543. "secret-rs",
  544. )
  545. .await?;
  546. assert!(response.active, "a live refresh token must be active");
  547. assert_eq!(response.scope.as_deref(), Some("orders:read"));
  548. assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
  549. assert_eq!(response.sub.as_deref(), Some("user-1"));
  550. // RFC 7662 §2.2: `token_type` is OPTIONAL; RFC 6749 §5.1 defines a token
  551. // type only for access tokens, so a refresh token reports none.
  552. assert_eq!(response.token_type, None);
  553. assert_eq!(response.cnf, None, "an unbound token has no confirmation");
  554. let iat = response.iat.ok_or("iat missing")?;
  555. let exp = response.exp.ok_or("exp missing")?;
  556. assert!(iat < exp, "iat must precede exp");
  557. assert!(exp > before, "exp must be in the future");
  558. let ttl = i64::try_from(h.config.ttl.refresh.as_secs())?;
  559. assert_eq!(exp - iat, ttl, "window must equal the refresh TTL");
  560. Ok(())
  561. }
  562. #[tokio::test]
  563. async fn test_introspect_dpop_bound_token_reports_jkt_only() -> TestResult {
  564. let mut h = harness().await?;
  565. let client_id = unique("dpop-bound");
  566. insert_client(
  567. &h.pool,
  568. &h.config.keys,
  569. &client_id,
  570. TokenEndpointAuthMethod::ClientSecretPost,
  571. Some("secret-dp"),
  572. )
  573. .await?;
  574. let minted = mint_bound(
  575. &mut h,
  576. &client_id,
  577. SenderProof {
  578. dpop_jkt: Some("jkt-dpop-only".to_owned()),
  579. mtls_x5t_s256: None,
  580. },
  581. )
  582. .await?;
  583. let response = run_core(
  584. &mut h,
  585. &minted.access_token,
  586. Some("access_token"),
  587. &client_id,
  588. "secret-dp",
  589. )
  590. .await?;
  591. // RFC 9449 §5/§6: a DPoP-bound token confirms the key with `cnf.jkt`.
  592. let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
  593. assert_eq!(cnf.jkt.as_deref(), Some("jkt-dpop-only"));
  594. assert_eq!(cnf.x5t_s256, None, "no mTLS binding was proven");
  595. // RFC 9449 §6.2: a DPoP-bound access token introspects as `DPoP`.
  596. assert_eq!(response.token_type.as_deref(), Some("DPoP"));
  597. let json = serde_json::to_value(&response)?;
  598. let cnf_json = json
  599. .get("cnf")
  600. .and_then(Value::as_object)
  601. .ok_or("cnf member missing")?;
  602. assert_eq!(
  603. cnf_json.get("jkt").and_then(Value::as_str),
  604. Some("jkt-dpop-only")
  605. );
  606. assert!(
  607. !cnf_json.contains_key("x5t#S256"),
  608. "the mTLS thumbprint must not leak into a DPoP-only response"
  609. );
  610. Ok(())
  611. }
  612. #[tokio::test]
  613. async fn test_introspect_mtls_bound_token_reports_x5t_only() -> TestResult {
  614. let mut h = harness().await?;
  615. let client_id = unique("mtls-bound");
  616. insert_client(
  617. &h.pool,
  618. &h.config.keys,
  619. &client_id,
  620. TokenEndpointAuthMethod::ClientSecretPost,
  621. Some("secret-mtls"),
  622. )
  623. .await?;
  624. let minted = mint_bound(
  625. &mut h,
  626. &client_id,
  627. SenderProof {
  628. dpop_jkt: None,
  629. mtls_x5t_s256: Some("x5t-mtls-only".to_owned()),
  630. },
  631. )
  632. .await?;
  633. let response = run_core(
  634. &mut h,
  635. &minted.access_token,
  636. Some("access_token"),
  637. &client_id,
  638. "secret-mtls",
  639. )
  640. .await?;
  641. // RFC 8705 §3.1: an mTLS-bound token confirms the certificate with
  642. // `cnf."x5t#S256"`.
  643. let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
  644. assert_eq!(cnf.x5t_s256.as_deref(), Some("x5t-mtls-only"));
  645. assert_eq!(cnf.jkt, None, "no DPoP binding was proven");
  646. let json = serde_json::to_value(&response)?;
  647. let cnf_json = json
  648. .get("cnf")
  649. .and_then(Value::as_object)
  650. .ok_or("cnf member missing")?;
  651. assert_eq!(
  652. cnf_json.get("x5t#S256").and_then(Value::as_str),
  653. Some("x5t-mtls-only")
  654. );
  655. assert!(
  656. !cnf_json.contains_key("jkt"),
  657. "the DPoP thumbprint must not leak into an mTLS-only response"
  658. );
  659. Ok(())
  660. }
  661. #[tokio::test]
  662. async fn test_introspect_unknown_hint_is_advisory() -> TestResult {
  663. let mut h = harness().await?;
  664. let client_id = unique("unknown-hint");
  665. insert_client(
  666. &h.pool,
  667. &h.config.keys,
  668. &client_id,
  669. TokenEndpointAuthMethod::ClientSecretPost,
  670. Some("secret-uh"),
  671. )
  672. .await?;
  673. let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
  674. let response = run_core(
  675. &mut h,
  676. &minted.access_token,
  677. Some("password"),
  678. &client_id,
  679. "secret-uh",
  680. )
  681. .await?;
  682. // RFC 7662 §2.1: an unknown `token_type_hint` is advisory only; the server
  683. // must not reject the request and must still resolve the token.
  684. assert!(
  685. response.active,
  686. "an unknown hint must not inactivate the token"
  687. );
  688. assert_eq!(response.token_type.as_deref(), Some("Bearer"));
  689. assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
  690. Ok(())
  691. }
  692. #[tokio::test]
  693. async fn test_introspect_wrong_hint_still_resolves_refresh() -> TestResult {
  694. let mut h = harness().await?;
  695. let client_id = unique("wrong-hint");
  696. insert_client(
  697. &h.pool,
  698. &h.config.keys,
  699. &client_id,
  700. TokenEndpointAuthMethod::ClientSecretPost,
  701. Some("secret-wh"),
  702. )
  703. .await?;
  704. let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
  705. let response = run_core(
  706. &mut h,
  707. &minted.refresh_token,
  708. Some("access_token"),
  709. &client_id,
  710. "secret-wh",
  711. )
  712. .await?;
  713. // RFC 7662 §2.1: a hint that misses must fall back to the other token type,
  714. // so the refresh token is still found and reported active.
  715. assert!(response.active, "the hint must not hide the refresh token");
  716. assert_eq!(response.token_type, None);
  717. assert_eq!(response.scope.as_deref(), Some("orders:read"));
  718. Ok(())
  719. }
  720. #[tokio::test]
  721. async fn test_introspect_active_response_leaks_no_token_or_secret() -> TestResult {
  722. let mut h = harness().await?;
  723. let client_id = unique("no-leak");
  724. insert_client(
  725. &h.pool,
  726. &h.config.keys,
  727. &client_id,
  728. TokenEndpointAuthMethod::ClientSecretBasic,
  729. Some("leak-secret"),
  730. )
  731. .await?;
  732. let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
  733. let auth = basic_header(&client_id, "leak-secret");
  734. let (status, bytes) = post_form_bytes(
  735. router(&h),
  736. Some(&auth),
  737. &format!("token={}", minted.access_token),
  738. )
  739. .await?;
  740. assert_eq!(status, StatusCode::OK);
  741. let raw = String::from_utf8(bytes)?;
  742. assert!(
  743. raw.contains("\"active\":true"),
  744. "expected an active response"
  745. );
  746. assert!(
  747. !raw.contains(&minted.access_token),
  748. "the presented token must never be echoed"
  749. );
  750. assert!(
  751. !raw.contains(&minted.refresh_token),
  752. "the sibling refresh token must never leak"
  753. );
  754. assert!(
  755. !raw.contains("leak-secret"),
  756. "the client secret must never leak"
  757. );
  758. Ok(())
  759. }
  760. #[tokio::test]
  761. async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
  762. let mut h = harness().await?;
  763. let client_id = unique("revoke-then-introspect");
  764. insert_client(
  765. &h.pool,
  766. &h.config.keys,
  767. &client_id,
  768. TokenEndpointAuthMethod::ClientSecretPost,
  769. Some("secret-ri"),
  770. )
  771. .await?;
  772. let minted = mint_bound(
  773. &mut h,
  774. &client_id,
  775. SenderProof {
  776. dpop_jkt: Some("jkt-revoked".to_owned()),
  777. mtls_x5t_s256: None,
  778. },
  779. )
  780. .await?;
  781. let outcome = {
  782. let service = token_service(&h)?;
  783. let auth = ClientAuthRequest {
  784. basic_header: None,
  785. client_id: Some(client_id.as_str()),
  786. body_secret: Some(SecretString::from("secret-ri")),
  787. assertion_type: None,
  788. assertion: None,
  789. peer_certs: None,
  790. };
  791. let mut stores = RevokeStores {
  792. pool: &h.pool,
  793. keys: &h.config.keys,
  794. conn: &mut h.conn,
  795. store: &h.store,
  796. service: &service,
  797. };
  798. let ctx = RevokeContext {
  799. token_endpoint: "https://auth.example.test/token",
  800. now: OffsetDateTime::now_utc(),
  801. cimd: None,
  802. request_id: Some("req-introspect-revoke"),
  803. };
  804. let request = RevokeRequest {
  805. token: &minted.access_token,
  806. token_type_hint: None,
  807. auth: &auth,
  808. };
  809. revoke_tokens(&mut stores, &ctx, &request, None).await?
  810. };
  811. assert_eq!(
  812. outcome,
  813. RevokeOutcome::Revoked,
  814. "the owner must revoke its own DPoP-bound token"
  815. );
  816. // RFC 7009 §2.2: after revocation the token is no longer usable, so
  817. // RFC 7662 §2.2 introspection reports the inactive shape.
  818. let response = run_core(
  819. &mut h,
  820. &minted.access_token,
  821. Some("access_token"),
  822. &client_id,
  823. "secret-ri",
  824. )
  825. .await?;
  826. assert!(!response.active, "a revoked token must not be active");
  827. assert_eq!(response, IntrospectionResponse::inactive());
  828. Ok(())
  829. }
  830. /// Mint one token pair owned by `client_id` with an explicit audience.
  831. async fn mint_audience(
  832. h: &mut Harness,
  833. client_id: &str,
  834. aud: &[&str],
  835. ) -> TestResult<crate::protocol::token::MintedTokens> {
  836. mint_audience_subject(h, client_id, aud, "user-1").await
  837. }
  838. /// Mint one token pair owned by `client_id` with an explicit audience and
  839. /// subject.
  840. async fn mint_audience_subject(
  841. h: &mut Harness,
  842. client_id: &str,
  843. aud: &[&str],
  844. subject: &str,
  845. ) -> TestResult<crate::protocol::token::MintedTokens> {
  846. let service = token_service(h)?;
  847. let aud: Vec<String> = aud.iter().map(|value| (*value).to_owned()).collect();
  848. Ok(
  849. service
  850. .mint(
  851. &mut h.conn,
  852. &h.store,
  853. &MintRequest {
  854. client_id: client_id.to_owned(),
  855. client_kind: ClientKind::Confidential,
  856. dpop_required: false,
  857. subject: subject.to_owned(),
  858. scope: "orders:read".to_owned(),
  859. access_scope: "orders:read".to_owned(),
  860. grant_id: unique("grant"),
  861. proof: SenderProof::none(),
  862. aud: aud.clone(),
  863. refresh_aud: aud,
  864. format: TokenFormat::Opaque,
  865. },
  866. now_unix(),
  867. )
  868. .await?,
  869. )
  870. }
  871. /// Mint one token pair owned by `client_id` with an explicit subject.
  872. async fn mint_subject(
  873. h: &mut Harness,
  874. client_id: &str,
  875. subject: &str,
  876. ) -> TestResult<crate::protocol::token::MintedTokens> {
  877. let service = token_service(h)?;
  878. Ok(
  879. service
  880. .mint(
  881. &mut h.conn,
  882. &h.store,
  883. &MintRequest {
  884. client_id: client_id.to_owned(),
  885. client_kind: ClientKind::Confidential,
  886. dpop_required: false,
  887. subject: subject.to_owned(),
  888. scope: "orders:read".to_owned(),
  889. access_scope: "orders:read".to_owned(),
  890. grant_id: unique("grant"),
  891. proof: SenderProof::none(),
  892. aud: Vec::new(),
  893. refresh_aud: Vec::new(),
  894. format: TokenFormat::Opaque,
  895. },
  896. now_unix(),
  897. )
  898. .await?,
  899. )
  900. }
  901. /// Provision one enabled user with a fast test password hash.
  902. async fn provision_user(h: &Harness, email: &str) -> TestResult<UserRecord> {
  903. let params = Argon2Params::new(8, 1, 1, 32).map_err(|error| format!("{error:?}"))?;
  904. Ok(
  905. user_repo::create_user(
  906. &h.pool,
  907. &h.config.keys,
  908. false,
  909. &NewUser {
  910. email: email.to_owned(),
  911. password_hash: hash_secret("correct-horse-01", &params)?,
  912. is_admin: false,
  913. must_change_password: false,
  914. },
  915. )
  916. .await?,
  917. )
  918. }
  919. async fn introspect_client(h: &mut Harness, label: &str, secret: &str) -> TestResult<String> {
  920. let client_id = unique(label);
  921. insert_client(
  922. &h.pool,
  923. &h.config.keys,
  924. &client_id,
  925. TokenEndpointAuthMethod::ClientSecretPost,
  926. Some(secret),
  927. )
  928. .await?;
  929. Ok(client_id)
  930. }
  931. #[tokio::test]
  932. async fn test_introspect_disabled_email_subject_is_inactive() -> TestResult {
  933. let mut h = harness().await?;
  934. let client_id = introspect_client(&mut h, "disabled-email", "secret-de").await?;
  935. let user = provision_user(&h, "disabled-introspect@example.test").await?;
  936. let minted = mint_subject(&mut h, &client_id, &user.email).await?;
  937. assert!(user_repo::disable_user(&h.pool, user.id).await?);
  938. let response = run_core(
  939. &mut h,
  940. &minted.access_token,
  941. Some("access_token"),
  942. &client_id,
  943. "secret-de",
  944. )
  945. .await?;
  946. assert!(!response.active, "a disabled user's token must be inactive");
  947. assert_eq!(response, IntrospectionResponse::inactive());
  948. Ok(())
  949. }
  950. #[tokio::test]
  951. async fn test_introspect_disabled_uuid_subject_is_inactive() -> TestResult {
  952. let mut h = harness().await?;
  953. let client_id = introspect_client(&mut h, "disabled-uuid", "secret-du").await?;
  954. let user = provision_user(&h, "disabled-uuid@example.test").await?;
  955. // Device-code tokens carry the user UUID string as subject.
  956. let minted = mint_subject(&mut h, &client_id, &user.id.to_string()).await?;
  957. assert!(user_repo::disable_user(&h.pool, user.id).await?);
  958. let response = run_core(
  959. &mut h,
  960. &minted.access_token,
  961. Some("access_token"),
  962. &client_id,
  963. "secret-du",
  964. )
  965. .await?;
  966. assert!(!response.active, "a disabled user's token must be inactive");
  967. assert_eq!(response, IntrospectionResponse::inactive());
  968. Ok(())
  969. }
  970. #[tokio::test]
  971. async fn test_introspect_enabled_email_subject_stays_active() -> TestResult {
  972. let mut h = harness().await?;
  973. let client_id = introspect_client(&mut h, "enabled-email", "secret-ee").await?;
  974. let user = provision_user(&h, "enabled-introspect@example.test").await?;
  975. let minted = mint_subject(&mut h, &client_id, &user.email).await?;
  976. let response = run_core(
  977. &mut h,
  978. &minted.access_token,
  979. Some("access_token"),
  980. &client_id,
  981. "secret-ee",
  982. )
  983. .await?;
  984. assert!(response.active, "an enabled user's token must stay active");
  985. assert_eq!(response.sub.as_deref(), Some(user.email.as_str()));
  986. Ok(())
  987. }
  988. #[tokio::test]
  989. async fn test_introspect_reenabled_subject_is_active_again() -> TestResult {
  990. let mut h = harness().await?;
  991. let client_id = introspect_client(&mut h, "reenabled", "secret-re").await?;
  992. let user = provision_user(&h, "reenabled-introspect@example.test").await?;
  993. let minted = mint_subject(&mut h, &client_id, &user.email).await?;
  994. assert!(user_repo::disable_user(&h.pool, user.id).await?);
  995. let disabled = run_core(
  996. &mut h,
  997. &minted.access_token,
  998. Some("access_token"),
  999. &client_id,
  1000. "secret-re",
  1001. )
  1002. .await?;
  1003. assert!(!disabled.active, "a disabled user's token must be inactive");
  1004. sqlx::query("UPDATE users SET disabled = false, updated_at = now() WHERE id = $1")
  1005. .bind(user.id)
  1006. .execute(&h.pool)
  1007. .await?;
  1008. let response = run_core(
  1009. &mut h,
  1010. &minted.access_token,
  1011. Some("access_token"),
  1012. &client_id,
  1013. "secret-re",
  1014. )
  1015. .await?;
  1016. assert!(
  1017. response.active,
  1018. "a re-enabled user's token must be active again"
  1019. );
  1020. Ok(())
  1021. }
  1022. #[tokio::test]
  1023. async fn test_introspect_empty_and_unknown_subjects_stay_active() -> TestResult {
  1024. let mut h = harness().await?;
  1025. let client_id = introspect_client(&mut h, "non-user-sub", "secret-nu").await?;
  1026. // Client-credentials tokens carry an empty subject.
  1027. let empty = mint_subject(&mut h, &client_id, "").await?;
  1028. let response = run_core(
  1029. &mut h,
  1030. &empty.access_token,
  1031. Some("access_token"),
  1032. &client_id,
  1033. "secret-nu",
  1034. )
  1035. .await?;
  1036. assert!(response.active, "an empty subject must stay active");
  1037. // Synthetic subjects resolve to no user and must stay live.
  1038. let synthetic = mint_subject(&mut h, &client_id, "synthetic-subject-9").await?;
  1039. let response = run_core(
  1040. &mut h,
  1041. &synthetic.access_token,
  1042. Some("access_token"),
  1043. &client_id,
  1044. "secret-nu",
  1045. )
  1046. .await?;
  1047. assert!(response.active, "an unmapped subject must stay active");
  1048. assert_eq!(response.sub.as_deref(), Some("synthetic-subject-9"));
  1049. Ok(())
  1050. }
  1051. #[tokio::test]
  1052. async fn test_introspect_enforces_requested_resource() -> TestResult {
  1053. let mut h = harness().await?;
  1054. let client_id = unique("resource-filter");
  1055. insert_client(
  1056. &h.pool,
  1057. &h.config.keys,
  1058. &client_id,
  1059. TokenEndpointAuthMethod::ClientSecretBasic,
  1060. Some("resource-secret"),
  1061. )
  1062. .await?;
  1063. let audience_a = "https://a.introspect.test/";
  1064. let audience_b = "https://b.introspect.test/";
  1065. let audience_c = "https://c.introspect.test/";
  1066. for identifier in [audience_a, audience_b, audience_c] {
  1067. resource_repo::upsert(&h.pool, identifier, "introspect audience").await?;
  1068. }
  1069. let minted = mint_audience(&mut h, &client_id, &[audience_a, audience_b]).await?;
  1070. let auth = basic_header(&client_id, "resource-secret");
  1071. let access = &minted.access_token;
  1072. // RFC 7662 defines no `resource` parameter; this server accepts it as a
  1073. // local extension. A subset of the token audience stays active.
  1074. let (status, body) = post_form(
  1075. router(&h),
  1076. Some(&auth),
  1077. &format!("token={access}&resource={audience_a}"),
  1078. )
  1079. .await?;
  1080. assert_eq!(status, StatusCode::OK);
  1081. assert_eq!(
  1082. body.get("active").and_then(Value::as_bool),
  1083. Some(true),
  1084. "a requested audience contained in the token aud must stay active"
  1085. );
  1086. // Repeated values are a set: both members are present, order-insensitive.
  1087. let (status, body) = post_form(
  1088. router(&h),
  1089. Some(&auth),
  1090. &format!("token={access}&resource={audience_a}&resource={audience_b}"),
  1091. )
  1092. .await?;
  1093. assert_eq!(status, StatusCode::OK);
  1094. assert_eq!(
  1095. body.get("active").and_then(Value::as_bool),
  1096. Some(true),
  1097. "the complete repeated set must stay active"
  1098. );
  1099. // A requested audience the token is not bound to collapses to the inactive
  1100. // shape: no scope, client_id, sub, cnf, or expiry may leak.
  1101. let (status, body) = post_form(
  1102. router(&h),
  1103. Some(&auth),
  1104. &format!("token={access}&resource={audience_c}"),
  1105. )
  1106. .await?;
  1107. assert_eq!(status, StatusCode::OK);
  1108. assert_eq!(
  1109. body,
  1110. json!({"active": false}),
  1111. "a mismatched audience must not report token metadata"
  1112. );
  1113. // Absent `resource` keeps RFC 7662 introspection audience-agnostic.
  1114. let (status, body) = post_form(router(&h), Some(&auth), &format!("token={access}")).await?;
  1115. assert_eq!(status, StatusCode::OK);
  1116. assert_eq!(
  1117. body.get("active").and_then(Value::as_bool),
  1118. Some(true),
  1119. "no resource parameter must preserve today's behavior"
  1120. );
  1121. assert_eq!(
  1122. body.get("scope").and_then(Value::as_str),
  1123. Some("orders:read"),
  1124. "an unfiltered active response keeps its metadata"
  1125. );
  1126. // The filter follows the token record, not the hint: the access token
  1127. // resolves through the refresh hint (RFC 7662 §2.1 fallback) and stays
  1128. // filtered, and the refresh token is filtered under its own hint.
  1129. let (status, body) = post_form(
  1130. router(&h),
  1131. Some(&auth),
  1132. &format!("token={access}&token_type_hint=refresh_token&resource={audience_c}"),
  1133. )
  1134. .await?;
  1135. assert_eq!(status, StatusCode::OK);
  1136. assert_eq!(
  1137. body,
  1138. json!({"active": false}),
  1139. "hint ordering must not bypass the audience filter"
  1140. );
  1141. let (status, body) = post_form(
  1142. router(&h),
  1143. Some(&auth),
  1144. &format!(
  1145. "token={}&token_type_hint=refresh_token&resource={audience_a}",
  1146. minted.refresh_token
  1147. ),
  1148. )
  1149. .await?;
  1150. assert_eq!(status, StatusCode::OK);
  1151. assert_eq!(
  1152. body.get("active").and_then(Value::as_bool),
  1153. Some(true),
  1154. "a refresh token bound to the requested audience must stay active"
  1155. );
  1156. // An unbound token (empty aud) cannot satisfy any requested audience.
  1157. let empty = mint_audience(&mut h, &client_id, &[]).await?;
  1158. let (status, body) = post_form(
  1159. router(&h),
  1160. Some(&auth),
  1161. &format!("token={}&resource={audience_a}", empty.access_token),
  1162. )
  1163. .await?;
  1164. assert_eq!(status, StatusCode::OK);
  1165. assert_eq!(
  1166. body,
  1167. json!({"active": false}),
  1168. "an empty token audience must not satisfy a requested resource"
  1169. );
  1170. Ok(())
  1171. }
  1172. #[tokio::test]
  1173. async fn test_introspect_reports_aud() -> TestResult {
  1174. let mut h = harness().await?;
  1175. let client_id = unique("aud-report");
  1176. insert_client(
  1177. &h.pool,
  1178. &h.config.keys,
  1179. &client_id,
  1180. TokenEndpointAuthMethod::ClientSecretBasic,
  1181. Some("secret-au"),
  1182. )
  1183. .await?;
  1184. let audience_a = "https://a.aud-introspect.test/";
  1185. let audience_b = "https://b.aud-introspect.test/";
  1186. for identifier in [audience_a, audience_b] {
  1187. resource_repo::upsert(&h.pool, identifier, "introspect audience").await?;
  1188. }
  1189. let auth = basic_header(&client_id, "secret-au");
  1190. // RFC 9068 §2.2 array shape: an aud-bound token reports the exact stored
  1191. // set as a JSON array, even though RFC 7662 §2.2 documents a string.
  1192. let minted = mint_audience(&mut h, &client_id, &[audience_a, audience_b]).await?;
  1193. let (status, body) = post_form(
  1194. router(&h),
  1195. Some(&auth),
  1196. &format!("token={}", minted.access_token),
  1197. )
  1198. .await?;
  1199. assert_eq!(status, StatusCode::OK);
  1200. assert_eq!(body.get("active").and_then(Value::as_bool), Some(true));
  1201. assert_eq!(
  1202. body.get("aud"),
  1203. Some(&json!([audience_a, audience_b])),
  1204. "the access token must report its exact stored audience set"
  1205. );
  1206. // A single audience is still a one-element array, never a bare string.
  1207. let single = mint_audience(&mut h, &client_id, &[audience_a]).await?;
  1208. let (status, body) = post_form(
  1209. router(&h),
  1210. Some(&auth),
  1211. &format!("token={}", single.access_token),
  1212. )
  1213. .await?;
  1214. assert_eq!(status, StatusCode::OK);
  1215. let aud = body.get("aud").ok_or("aud member missing")?;
  1216. assert!(aud.is_array(), "aud must be a JSON array, not a string");
  1217. assert_eq!(aud, &json!([audience_a]));
  1218. // The same rule applies to a refresh record that carries an audience.
  1219. let (status, body) = post_form(
  1220. router(&h),
  1221. Some(&auth),
  1222. &format!(
  1223. "token={}&token_type_hint=refresh_token",
  1224. minted.refresh_token
  1225. ),
  1226. )
  1227. .await?;
  1228. assert_eq!(status, StatusCode::OK);
  1229. assert_eq!(body.get("active").and_then(Value::as_bool), Some(true));
  1230. assert_eq!(
  1231. body.get("aud"),
  1232. Some(&json!([audience_a, audience_b])),
  1233. "a refresh record carrying an audience must report it"
  1234. );
  1235. // An unbound token omits the member entirely.
  1236. let unbound = mint_audience(&mut h, &client_id, &[]).await?;
  1237. let (status, body) = post_form(
  1238. router(&h),
  1239. Some(&auth),
  1240. &format!("token={}", unbound.access_token),
  1241. )
  1242. .await?;
  1243. assert_eq!(status, StatusCode::OK);
  1244. assert!(body.get("aud").is_none(), "an unbound token must omit aud");
  1245. // A disabled subject collapses to the RFC 7662 §2.2 inactive shape with no
  1246. // audience leak, even when the record carries one.
  1247. let user = provision_user(&h, "aud-disabled@example.test").await?;
  1248. let disabled = mint_audience_subject(&mut h, &client_id, &[audience_a], &user.email).await?;
  1249. assert!(user_repo::disable_user(&h.pool, user.id).await?);
  1250. let (status, body) = post_form(
  1251. router(&h),
  1252. Some(&auth),
  1253. &format!("token={}", disabled.access_token),
  1254. )
  1255. .await?;
  1256. assert_eq!(status, StatusCode::OK);
  1257. assert_eq!(body, json!({"active": false}));
  1258. assert!(
  1259. body.get("aud").is_none(),
  1260. "an inactive response must not leak aud"
  1261. );
  1262. // An unknown token is inactive and carries no aud.
  1263. let (status, body) = post_form(router(&h), Some(&auth), "token=not-a-real-aud-token").await?;
  1264. assert_eq!(status, StatusCode::OK);
  1265. assert_eq!(body, json!({"active": false}));
  1266. Ok(())
  1267. }
  1268. /// Mint one JWT access token owned by `client_id` through the configured
  1269. /// signer (Todo 26 branch): the returned token verifies under the harness
  1270. /// config, so the introspection JWT path must accept it.
  1271. async fn mint_jwt(
  1272. h: &mut Harness,
  1273. client_id: &str,
  1274. aud: &[&str],
  1275. subject: &str,
  1276. proof: SenderProof,
  1277. ) -> TestResult<String> {
  1278. let signer = JwtAccessSigner::from_config(&h.config)?;
  1279. let service = token_service(h)?.with_jwt_signer(signer);
  1280. let aud: Vec<String> = aud.iter().map(|value| (*value).to_owned()).collect();
  1281. let minted = service
  1282. .mint(
  1283. &mut h.conn,
  1284. &h.store,
  1285. &MintRequest {
  1286. client_id: client_id.to_owned(),
  1287. client_kind: ClientKind::Confidential,
  1288. dpop_required: false,
  1289. subject: subject.to_owned(),
  1290. scope: "orders:read".to_owned(),
  1291. access_scope: "orders:read".to_owned(),
  1292. grant_id: unique("grant"),
  1293. proof,
  1294. aud: aud.clone(),
  1295. refresh_aud: aud,
  1296. format: TokenFormat::Jwt,
  1297. },
  1298. now_unix(),
  1299. )
  1300. .await?;
  1301. if minted.access_token.split('.').count() != 3 {
  1302. return Err("a jwt mint must return three JOSE segments".into());
  1303. }
  1304. Ok(minted.access_token)
  1305. }
  1306. /// Flip the first signature character so the token still parses but the
  1307. /// signature breaks (flipping the last char trips base64 padding instead).
  1308. fn tamper_signature(token: &str) -> String {
  1309. let mut parts: Vec<String> = token.split('.').map(str::to_owned).collect();
  1310. let sig = parts[2].clone();
  1311. let first = sig.chars().next().unwrap_or('a');
  1312. let replacement = if first == 'a' { 'b' } else { 'a' };
  1313. parts[2] = format!("{replacement}{}", &sig[1..]);
  1314. parts.join(".")
  1315. }
  1316. #[tokio::test]
  1317. async fn test_introspect_dual_path() -> TestResult {
  1318. let mut h = harness().await?;
  1319. let client_id = unique("dual-owner");
  1320. insert_client(
  1321. &h.pool,
  1322. &h.config.keys,
  1323. &client_id,
  1324. TokenEndpointAuthMethod::ClientSecretPost,
  1325. Some("secret-dual"),
  1326. )
  1327. .await?;
  1328. let foreign = unique("dual-foreign");
  1329. insert_client(
  1330. &h.pool,
  1331. &h.config.keys,
  1332. &foreign,
  1333. TokenEndpointAuthMethod::ClientSecretPost,
  1334. Some("secret-foreign"),
  1335. )
  1336. .await?;
  1337. let audience_a = "https://a.dual-introspect.test/";
  1338. let audience_b = "https://b.dual-introspect.test/";
  1339. let audience_c = "https://c.dual-introspect.test/";
  1340. for identifier in [audience_a, audience_b, audience_c] {
  1341. resource_repo::upsert(&h.pool, identifier, "dual audience").await?;
  1342. }
  1343. // Opaque regression: an opaque access token stays active with its stored
  1344. // aud, and a refresh hint still falls back to the access record.
  1345. let opaque = mint_audience_subject(&mut h, &client_id, &[audience_a], "opaque-sub").await?;
  1346. let response = run_core_full(
  1347. &mut h,
  1348. &opaque.access_token,
  1349. None,
  1350. &client_id,
  1351. "secret-dual",
  1352. &[],
  1353. true,
  1354. )
  1355. .await?;
  1356. assert!(response.active, "an opaque token must stay active");
  1357. assert_eq!(response.token_type.as_deref(), Some("Bearer"));
  1358. assert_eq!(response.aud, Some(vec![audience_a.to_owned()]));
  1359. let hinted = run_core_full(
  1360. &mut h,
  1361. &opaque.access_token,
  1362. Some("refresh_token"),
  1363. &client_id,
  1364. "secret-dual",
  1365. &[],
  1366. true,
  1367. )
  1368. .await?;
  1369. assert!(
  1370. hinted.active,
  1371. "an opaque token under a refresh hint must still resolve"
  1372. );
  1373. assert_eq!(hinted.token_type.as_deref(), Some("Bearer"));
  1374. // JWT: active with aud, token_type, client_id, sub, scope, exp/iat, cnf,
  1375. // and no leaked JOSE internals.
  1376. let jwt = mint_jwt(
  1377. &mut h,
  1378. &client_id,
  1379. &[audience_a, audience_b],
  1380. "jwt-subject-1",
  1381. SenderProof {
  1382. dpop_jkt: Some("dual-jkt".to_owned()),
  1383. mtls_x5t_s256: None,
  1384. },
  1385. )
  1386. .await?;
  1387. let before = now_unix();
  1388. let response = run_core_full(&mut h, &jwt, None, &client_id, "secret-dual", &[], true).await?;
  1389. assert!(response.active, "a live JWT must introspect active");
  1390. assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
  1391. assert_eq!(response.scope.as_deref(), Some("orders:read"));
  1392. assert_eq!(response.sub.as_deref(), Some("jwt-subject-1"));
  1393. assert_eq!(response.token_type.as_deref(), Some("DPoP"));
  1394. assert_eq!(
  1395. response.aud,
  1396. Some(vec![audience_a.to_owned(), audience_b.to_owned()])
  1397. );
  1398. let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
  1399. assert_eq!(cnf.jkt.as_deref(), Some("dual-jkt"));
  1400. let iat = response.iat.ok_or("iat missing")?;
  1401. let exp = response.exp.ok_or("exp missing")?;
  1402. assert!(exp > before, "exp must be in the future");
  1403. let ttl = i64::try_from(h.config.ttl.access.as_secs())?;
  1404. assert_eq!(exp - iat, ttl, "the JWT window rides the claims");
  1405. assert_eq!(response.act, None, "a JWT carries no sidecar act");
  1406. let json = serde_json::to_value(&response)?;
  1407. assert!(
  1408. json.get("jti").is_none(),
  1409. "the allowlist jti must never leak into the response"
  1410. );
  1411. assert!(
  1412. !serde_json::to_string(&json)?.contains(&jwt),
  1413. "the presented JWT must never be echoed"
  1414. );
  1415. // The hint is advisory for opaque lookups and irrelevant for JWTs: a
  1416. // refresh hint must not change the JWT verdict.
  1417. let hinted_jwt = run_core_full(
  1418. &mut h,
  1419. &jwt,
  1420. Some("refresh_token"),
  1421. &client_id,
  1422. "secret-dual",
  1423. &[],
  1424. true,
  1425. )
  1426. .await?;
  1427. assert!(
  1428. hinted_jwt.active,
  1429. "a token_type_hint must not change the JWT path"
  1430. );
  1431. // A JWT owned by another client collapses to the same inactive shape as
  1432. // an unknown token (RFC 7662 §2.2 indistinguishability).
  1433. let foreign_response =
  1434. run_core_full(&mut h, &jwt, None, &foreign, "secret-foreign", &[], true).await?;
  1435. assert!(
  1436. !foreign_response.active,
  1437. "a cross-client JWT must be inactive"
  1438. );
  1439. assert_eq!(foreign_response, IntrospectionResponse::inactive());
  1440. // A tampered signature, an unknown marker (signed but never minted), and
  1441. // an expired JWT are all inactive, never errors.
  1442. let tampered = tamper_signature(&jwt);
  1443. let response = run_core_full(
  1444. &mut h,
  1445. &tampered,
  1446. None,
  1447. &client_id,
  1448. "secret-dual",
  1449. &[],
  1450. true,
  1451. )
  1452. .await?;
  1453. assert!(!response.active, "a tampered JWT must be inactive");
  1454. let signer = JwtAccessSigner::from_config(&h.config)?;
  1455. let ghost = signer.sign(&JwtClaims {
  1456. iss: h.config.issuer.clone(),
  1457. sub: "ghost-subject".to_owned(),
  1458. aud: vec![audience_a.to_owned()],
  1459. scope: "orders:read".to_owned(),
  1460. exp: now_unix() + 3_600,
  1461. iat: now_unix(),
  1462. jti: unique("ghost-jti"),
  1463. client_id: client_id.clone(),
  1464. cnf: None,
  1465. act: None,
  1466. })?;
  1467. let response = run_core_full(&mut h, &ghost, None, &client_id, "secret-dual", &[], true).await?;
  1468. assert!(!response.active, "a JWT with no marker must be inactive");
  1469. let stale = signer.sign(&JwtClaims {
  1470. iss: h.config.issuer.clone(),
  1471. sub: "ghost-subject".to_owned(),
  1472. aud: vec![audience_a.to_owned()],
  1473. scope: "orders:read".to_owned(),
  1474. exp: now_unix() - 10,
  1475. iat: now_unix() - 3_610,
  1476. jti: unique("stale-jti"),
  1477. client_id: client_id.clone(),
  1478. cnf: None,
  1479. act: None,
  1480. })?;
  1481. let response = run_core_full(&mut h, &stale, None, &client_id, "secret-dual", &[], true).await?;
  1482. assert!(!response.active, "an expired JWT must be inactive");
  1483. // A disabled subject renders the live JWT inactive with no aud leak.
  1484. let user = provision_user(&h, "dual-disabled@example.test").await?;
  1485. let bound = mint_jwt(
  1486. &mut h,
  1487. &client_id,
  1488. &[audience_a],
  1489. &user.email,
  1490. SenderProof::none(),
  1491. )
  1492. .await?;
  1493. assert!(user_repo::disable_user(&h.pool, user.id).await?);
  1494. let response = run_core_full(&mut h, &bound, None, &client_id, "secret-dual", &[], true).await?;
  1495. assert!(
  1496. !response.active,
  1497. "a disabled subject's JWT must be inactive"
  1498. );
  1499. assert_eq!(response, IntrospectionResponse::inactive());
  1500. // Flag off: the same live JWT introspects inactive (never 5xx). The core
  1501. // returns `Ok`, so reaching this line already proves no error was raised.
  1502. let response = run_core_full(&mut h, &jwt, None, &client_id, "secret-dual", &[], false).await?;
  1503. assert!(!response.active, "a JWT with the flag off must be inactive");
  1504. assert_eq!(response, IntrospectionResponse::inactive());
  1505. // Request-`resource` parity over HTTP (handler validation included): a
  1506. // requested audience the JWT lacks is inactive; one it has stays active.
  1507. // The fixture client is `ClientSecretPost`, so the HTTP calls carry the
  1508. // credentials in the body (a Basic header against a post client is 401).
  1509. let credentials = format!("client_id={client_id}&client_secret=secret-dual");
  1510. let (status, body) = post_form(
  1511. router(&h),
  1512. None,
  1513. &format!("token={jwt}&resource={audience_c}&{credentials}"),
  1514. )
  1515. .await?;
  1516. assert_eq!(status, StatusCode::OK);
  1517. assert_eq!(
  1518. body,
  1519. json!({"active": false}),
  1520. "a JWT missing the requested resource must be inactive"
  1521. );
  1522. let (status, body) = post_form(
  1523. router(&h),
  1524. None,
  1525. &format!("token={jwt}&resource={audience_a}&{credentials}"),
  1526. )
  1527. .await?;
  1528. assert_eq!(status, StatusCode::OK);
  1529. assert_eq!(
  1530. body.get("active").and_then(Value::as_bool),
  1531. Some(true),
  1532. "a JWT carrying the requested resource must stay active"
  1533. );
  1534. Ok(())
  1535. }