| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640 |
- //! `test(introspect)` suite: active shape, inactive misses, 401, ownership.
- //!
- //! Each test runs against the shared compose Redis (a per-test key-prefix
- //! pair isolates parallel runs) plus a migrated Postgres database on the shared
- //! test server, through the public [`super`] API and a one-route router for the
- //! HTTP status assertions.
- use std::sync::{
- Arc,
- atomic::{AtomicU64, Ordering},
- };
- use axum::{
- Router,
- body::Body,
- http::{Request, StatusCode, header},
- routing::post,
- };
- use base64::Engine as _;
- use http_body_util::BodyExt as _;
- use secrecy::SecretString;
- use serde_json::{Value, json};
- use sqlx::PgPool;
- use time::OffsetDateTime;
- use tower::ServiceExt as _;
- use uuid::Uuid;
- use super::{
- IntrospectContext, IntrospectHttpInputs, IntrospectStores, IntrospectionResponse, handler,
- introspect, record_act,
- };
- use crate::{
- config::Config,
- crypto::secret::{Argon2Params, hash_secret},
- db::{
- NewClient, TokenEndpointAuthMethod, client_repo, resource_repo,
- test_support::migrated_pool,
- user_repo::{self, NewUser, UserRecord},
- },
- jose::jwt_access::{JwtAccessSigner, JwtClaims},
- protocol::{
- client_auth::ClientAuthRequest,
- revoke::{RevokeContext, RevokeOutcome, RevokeRequest, RevokeStores, revoke_tokens},
- token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService},
- },
- state::{AppState, Clock, RedisConn},
- store::Store,
- test_guard::Lease,
- };
- /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
- type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
- static NONCE: AtomicU64 = AtomicU64::new(1);
- fn unique(label: &str) -> String {
- let n = NONCE.fetch_add(1, Ordering::Relaxed);
- format!("introspect-test-{n}-{label}")
- }
- fn now_unix() -> i64 {
- OffsetDateTime::now_utc().unix_timestamp()
- }
- /// Live handles for one test: migrated Postgres, Redis, store, and config.
- struct Harness {
- _db: Lease<crate::db::test_support::Db>,
- pool: PgPool,
- conn: RedisConn,
- store: Store,
- config: Arc<Config>,
- }
- async fn harness() -> TestResult<Harness> {
- let (db, pool) = migrated_pool().await?;
- let redis_url = crate::test_support::test_redis_url();
- let manager = crate::test_support::redis_manager().await?;
- let (token_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
- let store = Store::from_parts(
- token_prefix.clone(),
- ratelimit_prefix.clone(),
- crate::config::DurabilityMode::WaitAof,
- );
- let fixture = json!({
- "issuer": "https://auth.example.test",
- "environment": "development",
- "tls": {"cert_path": "/tmp/introspect-test-cert.pem", "key_path": "/tmp/introspect-test-key.pem"},
- "database": {"url": "postgres://127.0.0.1:1/introspect"},
- "redis": {
- "url": redis_url,
- "durability": "waitaof",
- "token_key_prefix": token_prefix,
- "ratelimit_key_prefix": ratelimit_prefix,
- },
- "token_pepper": "introspect-test-pepper-0123456789abcdef",
- "totp_encryption_key": "introspect-test-totp-key-0123456789abcdef",
- "pii_encryption_key": "introspect-test-pii-key-0123456789abcdef",
- "resource_indicators_enabled": true,
- "jwt_access_tokens_enabled": true,
- "jwt_signing_key": "bcdefghijklmnopqrstuvwxyzabcdefg",
- });
- let source = ::config::Config::try_from(&fixture)?;
- let config = Config::from_source(source)?;
- Ok(Harness {
- _db: db,
- pool,
- conn: RedisConn::Single(manager),
- store,
- config: Arc::new(config),
- })
- }
- async fn insert_client(
- pool: &PgPool,
- keys: &crate::keys::KeyRing,
- client_id: &str,
- method: TokenEndpointAuthMethod,
- secret: Option<&str>,
- ) -> TestResult {
- let params = Argon2Params::new(8, 1, 1, 32).map_err(|error| format!("{error:?}"))?;
- let secret_hash = secret
- .map(|value| hash_secret(value, ¶ms))
- .transpose()?;
- let new = NewClient {
- id: Uuid::new_v4(),
- client_id: client_id.to_owned(),
- client_secret_hash: secret_hash,
- client_secret_expires_at: None,
- client_name: format!("{client_id} name"),
- grant_types: vec!["client_credentials".to_owned()],
- response_types: Vec::new(),
- scope: "orders:read".to_owned(),
- token_endpoint_auth_method: method.as_str().to_owned(),
- jwks: None,
- jwks_uri: None,
- tls_client_auth_subject_dn: None,
- tls_client_auth_cert_thumbprint: None,
- request_uris: None,
- contacts: None,
- software_id: None,
- dpop_bound_access_tokens: false,
- access_token_format: "opaque".to_owned(),
- registration_access_token_hash: None,
- };
- client_repo::insert(pool, keys, false, &new).await?;
- Ok(())
- }
- fn token_service(h: &Harness) -> TestResult<TokenService> {
- Ok(TokenService::new(&h.config.ttl, &h.config.keys)?)
- }
- /// Mint one token pair owned by `client_id` with an explicit sender proof.
- async fn mint_bound(
- h: &mut Harness,
- client_id: &str,
- proof: SenderProof,
- ) -> TestResult<crate::protocol::token::MintedTokens> {
- let service = token_service(h)?;
- Ok(
- service
- .mint(
- &mut h.conn,
- &h.store,
- &MintRequest {
- client_id: client_id.to_owned(),
- client_kind: ClientKind::Confidential,
- dpop_required: false,
- subject: "user-1".to_owned(),
- scope: "orders:read".to_owned(),
- access_scope: "orders:read".to_owned(),
- grant_id: unique("grant"),
- proof,
- aud: Vec::new(),
- refresh_aud: Vec::new(),
- format: TokenFormat::Opaque,
- },
- now_unix(),
- )
- .await?,
- )
- }
- /// Mint one sender-constrained access token owned by `client_id`.
- async fn mint_constrained(
- h: &mut Harness,
- client_id: &str,
- ) -> TestResult<crate::protocol::token::MintedTokens> {
- mint_bound(
- h,
- client_id,
- SenderProof {
- dpop_jkt: Some("jkt-thumbnail".to_owned()),
- mtls_x5t_s256: Some("x5t-thumbnail".to_owned()),
- },
- )
- .await
- }
- /// Drive the core with `client_secret_post` credentials.
- async fn run_core(
- h: &mut Harness,
- token: &str,
- hint: Option<&str>,
- client_id: &str,
- secret: &str,
- ) -> TestResult<IntrospectionResponse> {
- run_core_with_resources(h, token, hint, client_id, secret, &[]).await
- }
- /// Drive the core with an explicit validated `resource` set, mirroring the
- /// handler: the JWT signer is built from the harness config (never cached, so
- /// rotation takes effect).
- async fn run_core_with_resources(
- h: &mut Harness,
- token: &str,
- hint: Option<&str>,
- client_id: &str,
- secret: &str,
- resource: &[String],
- ) -> TestResult<IntrospectionResponse> {
- run_core_full(h, token, hint, client_id, secret, resource, true).await
- }
- /// Core driver with an explicit JWT-signer switch: `with_signer = false`
- /// reproduces the feature-flag-off handler (signer absent, so a JWT-shaped
- /// token introspects inactive).
- async fn run_core_full(
- h: &mut Harness,
- token: &str,
- hint: Option<&str>,
- client_id: &str,
- secret: &str,
- resource: &[String],
- with_signer: bool,
- ) -> TestResult<IntrospectionResponse> {
- let service = token_service(h)?;
- let signer = with_signer
- .then(|| JwtAccessSigner::from_config(&h.config))
- .transpose()?;
- let mut stores = IntrospectStores {
- pool: &h.pool,
- keys: &h.config.keys,
- conn: &mut h.conn,
- store: &h.store,
- };
- let ctx = IntrospectContext {
- issuer: &h.config.issuer,
- now_unix: now_unix(),
- resource_indicators_enabled: true,
- cimd: None,
- service: &service,
- jwt_signer: signer.as_ref(),
- };
- let inputs = IntrospectHttpInputs {
- token,
- token_type_hint: hint,
- client_id: Some(client_id),
- basic_header: None,
- body_secret: Some(SecretString::from(secret)),
- assertion_type: None,
- assertion: None,
- peer_certs: None,
- resource,
- };
- Ok(introspect(&mut stores, &ctx, &inputs).await?)
- }
- fn app_state(h: &Harness) -> AppState {
- AppState {
- pool: h.pool.clone(),
- redis: h.conn.clone(),
- config: Arc::clone(&h.config),
- keys: Arc::new(h.config.keys.clone()),
- transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
- clock: Clock::system(),
- }
- }
- fn basic_header(client_id: &str, secret: &str) -> String {
- let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{client_id}:{secret}"));
- format!("Basic {encoded}")
- }
- async fn post_form_bytes(
- app: Router,
- auth: Option<&str>,
- body: &str,
- ) -> TestResult<(StatusCode, Vec<u8>)> {
- let mut builder = Request::builder()
- .method("POST")
- .uri("/introspect")
- .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
- if let Some(value) = auth {
- builder = builder.header(header::AUTHORIZATION, value);
- }
- let request = builder.body(Body::from(body.to_owned()))?;
- let response = app.oneshot(request).await?;
- let status = response.status();
- let bytes = response.into_body().collect().await?.to_bytes();
- Ok((status, bytes.to_vec()))
- }
- async fn post_form(app: Router, auth: Option<&str>, body: &str) -> TestResult<(StatusCode, Value)> {
- let (status, bytes) = post_form_bytes(app, auth, body).await?;
- Ok((status, serde_json::from_slice(&bytes)?))
- }
- fn router(h: &Harness) -> Router {
- Router::new()
- .route("/introspect", post(handler))
- .with_state(app_state(h))
- }
- #[tokio::test]
- async fn test_introspect_active_access_token_reports_shape() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("owner");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("secret-a"),
- )
- .await?;
- let minted = mint_constrained(&mut h, &client_id).await?;
- record_act(
- &mut h.conn,
- &h.store,
- &minted.access_hash,
- &json!({"sub": "actor-1"}),
- h.config.ttl.access,
- )
- .await?;
- let before = now_unix();
- let response = run_core(
- &mut h,
- &minted.access_token,
- Some("access_token"),
- &client_id,
- "secret-a",
- )
- .await?;
- assert!(response.active, "minted token must be active");
- assert_eq!(response.scope.as_deref(), Some("orders:read"));
- assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
- assert_eq!(response.token_type.as_deref(), Some("DPoP"));
- assert_eq!(response.sub.as_deref(), Some("user-1"));
- let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
- assert_eq!(cnf.jkt.as_deref(), Some("jkt-thumbnail"));
- assert_eq!(cnf.x5t_s256.as_deref(), Some("x5t-thumbnail"));
- assert_eq!(response.act, Some(json!({"sub": "actor-1"})));
- let iat = response.iat.ok_or("iat missing")?;
- let exp = response.exp.ok_or("exp missing")?;
- assert!(iat < exp, "iat must precede exp");
- assert!(exp > before, "exp must be in the future");
- let ttl = i64::try_from(h.config.ttl.access.as_secs())?;
- assert_eq!(exp - iat, ttl, "window must equal the access TTL");
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_unknown_token_is_inactive_200() -> TestResult {
- let h = harness().await?;
- let client_id = unique("unknown");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretBasic,
- Some("secret-u"),
- )
- .await?;
- let auth = basic_header(&client_id, "secret-u");
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- "token=not-a-real-token&token_type_hint=access_token",
- )
- .await?;
- assert_eq!(status, StatusCode::OK, "unknown token must not 4xx");
- assert_eq!(body, json!({"active": false}), "no claims may leak");
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_unauthenticated_is_401() -> TestResult {
- let h = harness().await?;
- let (status, body) = post_form(router(&h), None, "token=whatever").await?;
- assert_eq!(status, StatusCode::UNAUTHORIZED);
- assert_eq!(
- body.get("error").and_then(Value::as_str),
- Some("invalid_client")
- );
- Ok(())
- }
- /// Authentication precedes catalog validation: an unauthenticated caller
- /// presenting an unknown `resource` observes `invalid_client` 401, never
- /// `invalid_target`.
- #[tokio::test]
- async fn test_introspect_unauthenticated_with_unknown_resource_is_401() -> TestResult {
- let h = harness().await?;
- let (status, body) = post_form(
- router(&h),
- None,
- "token=whatever&resource=https://ghost.introspect.test/",
- )
- .await?;
- assert_eq!(status, StatusCode::UNAUTHORIZED);
- assert_eq!(
- body.get("error").and_then(Value::as_str),
- Some("invalid_client"),
- "auth must run before the catalog check"
- );
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
- let mut h = harness().await?;
- let owner = unique("owner");
- insert_client(
- &h.pool,
- &h.config.keys,
- &owner,
- TokenEndpointAuthMethod::ClientSecretBasic,
- Some("owner-secret"),
- )
- .await?;
- let foreign = unique("foreign");
- insert_client(
- &h.pool,
- &h.config.keys,
- &foreign,
- TokenEndpointAuthMethod::ClientSecretBasic,
- Some("foreign-secret"),
- )
- .await?;
- let minted = mint_constrained(&mut h, &owner).await?;
- let auth = basic_header(&foreign, "foreign-secret");
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!("token={}", minted.access_token),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_ne!(status, StatusCode::FORBIDDEN, "existence must not leak");
- assert_eq!(body, json!({"active": false}));
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_revoked_token_is_inactive() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("revoked");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("secret-r"),
- )
- .await?;
- let minted = mint_constrained(&mut h, &client_id).await?;
- let service = token_service(&h)?;
- let revoked = service
- .revoke_token(&mut h.conn, &h.store, &minted.access_token)
- .await?;
- assert!(revoked, "revoke must delete a live token");
- let response = run_core(
- &mut h,
- &minted.access_token,
- Some("access_token"),
- &client_id,
- "secret-r",
- )
- .await?;
- assert!(!response.active, "revoked token must be inactive");
- assert_eq!(response, IntrospectionResponse::inactive());
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_missing_token_form_is_400_invalid_request() -> TestResult {
- let h = harness().await?;
- let client_id = unique("missing-token");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretBasic,
- Some("secret-mt"),
- )
- .await?;
- let auth = basic_header(&client_id, "secret-mt");
- let (status, bytes) = post_form_bytes(
- router(&h),
- Some(&auth),
- "token_type_hint=access_token&client_secret=secret-mt",
- )
- .await?;
- // RFC 7662 §2.1: `token` is REQUIRED; §2.3: a malformed request is
- // `invalid_request` (HTTP 400), never a silent inactive 200.
- assert_eq!(
- status,
- StatusCode::BAD_REQUEST,
- "a missing token parameter must be invalid_request 400"
- );
- let body: Value = serde_json::from_slice(&bytes)?;
- assert_eq!(
- body.get("error").and_then(Value::as_str),
- Some("invalid_request")
- );
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_empty_token_is_400_invalid_request() -> TestResult {
- let h = harness().await?;
- let client_id = unique("empty-token");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretBasic,
- Some("secret-et"),
- )
- .await?;
- let auth = basic_header(&client_id, "secret-et");
- let (status, bytes) = post_form_bytes(
- router(&h),
- Some(&auth),
- "token=&token_type_hint=access_token",
- )
- .await?;
- // RFC 7662 §2.1: an empty `token` is no token at all, so §2.3's
- // `invalid_request` applies rather than the inactive response.
- assert_eq!(
- status,
- StatusCode::BAD_REQUEST,
- "an empty token must be invalid_request 400"
- );
- let body: Value = serde_json::from_slice(&bytes)?;
- assert_eq!(
- body.get("error").and_then(Value::as_str),
- Some("invalid_request")
- );
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_refresh_token_reports_active_shape() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("refresh-shape");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("secret-rs"),
- )
- .await?;
- let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
- let before = now_unix();
- let response = run_core(
- &mut h,
- &minted.refresh_token,
- Some("refresh_token"),
- &client_id,
- "secret-rs",
- )
- .await?;
- assert!(response.active, "a live refresh token must be active");
- assert_eq!(response.scope.as_deref(), Some("orders:read"));
- assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
- assert_eq!(response.sub.as_deref(), Some("user-1"));
- // RFC 7662 §2.2: `token_type` is OPTIONAL; RFC 6749 §5.1 defines a token
- // type only for access tokens, so a refresh token reports none.
- assert_eq!(response.token_type, None);
- assert_eq!(response.cnf, None, "an unbound token has no confirmation");
- let iat = response.iat.ok_or("iat missing")?;
- let exp = response.exp.ok_or("exp missing")?;
- assert!(iat < exp, "iat must precede exp");
- assert!(exp > before, "exp must be in the future");
- let ttl = i64::try_from(h.config.ttl.refresh.as_secs())?;
- assert_eq!(exp - iat, ttl, "window must equal the refresh TTL");
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_dpop_bound_token_reports_jkt_only() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("dpop-bound");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("secret-dp"),
- )
- .await?;
- let minted = mint_bound(
- &mut h,
- &client_id,
- SenderProof {
- dpop_jkt: Some("jkt-dpop-only".to_owned()),
- mtls_x5t_s256: None,
- },
- )
- .await?;
- let response = run_core(
- &mut h,
- &minted.access_token,
- Some("access_token"),
- &client_id,
- "secret-dp",
- )
- .await?;
- // RFC 9449 §5/§6: a DPoP-bound token confirms the key with `cnf.jkt`.
- let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
- assert_eq!(cnf.jkt.as_deref(), Some("jkt-dpop-only"));
- assert_eq!(cnf.x5t_s256, None, "no mTLS binding was proven");
- // RFC 9449 §6.2: a DPoP-bound access token introspects as `DPoP`.
- assert_eq!(response.token_type.as_deref(), Some("DPoP"));
- let json = serde_json::to_value(&response)?;
- let cnf_json = json
- .get("cnf")
- .and_then(Value::as_object)
- .ok_or("cnf member missing")?;
- assert_eq!(
- cnf_json.get("jkt").and_then(Value::as_str),
- Some("jkt-dpop-only")
- );
- assert!(
- !cnf_json.contains_key("x5t#S256"),
- "the mTLS thumbprint must not leak into a DPoP-only response"
- );
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_mtls_bound_token_reports_x5t_only() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("mtls-bound");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("secret-mtls"),
- )
- .await?;
- let minted = mint_bound(
- &mut h,
- &client_id,
- SenderProof {
- dpop_jkt: None,
- mtls_x5t_s256: Some("x5t-mtls-only".to_owned()),
- },
- )
- .await?;
- let response = run_core(
- &mut h,
- &minted.access_token,
- Some("access_token"),
- &client_id,
- "secret-mtls",
- )
- .await?;
- // RFC 8705 §3.1: an mTLS-bound token confirms the certificate with
- // `cnf."x5t#S256"`.
- let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
- assert_eq!(cnf.x5t_s256.as_deref(), Some("x5t-mtls-only"));
- assert_eq!(cnf.jkt, None, "no DPoP binding was proven");
- let json = serde_json::to_value(&response)?;
- let cnf_json = json
- .get("cnf")
- .and_then(Value::as_object)
- .ok_or("cnf member missing")?;
- assert_eq!(
- cnf_json.get("x5t#S256").and_then(Value::as_str),
- Some("x5t-mtls-only")
- );
- assert!(
- !cnf_json.contains_key("jkt"),
- "the DPoP thumbprint must not leak into an mTLS-only response"
- );
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_unknown_hint_is_advisory() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("unknown-hint");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("secret-uh"),
- )
- .await?;
- let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
- let response = run_core(
- &mut h,
- &minted.access_token,
- Some("password"),
- &client_id,
- "secret-uh",
- )
- .await?;
- // RFC 7662 §2.1: an unknown `token_type_hint` is advisory only; the server
- // must not reject the request and must still resolve the token.
- assert!(
- response.active,
- "an unknown hint must not inactivate the token"
- );
- assert_eq!(response.token_type.as_deref(), Some("Bearer"));
- assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_wrong_hint_still_resolves_refresh() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("wrong-hint");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("secret-wh"),
- )
- .await?;
- let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
- let response = run_core(
- &mut h,
- &minted.refresh_token,
- Some("access_token"),
- &client_id,
- "secret-wh",
- )
- .await?;
- // RFC 7662 §2.1: a hint that misses must fall back to the other token type,
- // so the refresh token is still found and reported active.
- assert!(response.active, "the hint must not hide the refresh token");
- assert_eq!(response.token_type, None);
- assert_eq!(response.scope.as_deref(), Some("orders:read"));
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_active_response_leaks_no_token_or_secret() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("no-leak");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretBasic,
- Some("leak-secret"),
- )
- .await?;
- let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
- let auth = basic_header(&client_id, "leak-secret");
- let (status, bytes) = post_form_bytes(
- router(&h),
- Some(&auth),
- &format!("token={}", minted.access_token),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- let raw = String::from_utf8(bytes)?;
- assert!(
- raw.contains("\"active\":true"),
- "expected an active response"
- );
- assert!(
- !raw.contains(&minted.access_token),
- "the presented token must never be echoed"
- );
- assert!(
- !raw.contains(&minted.refresh_token),
- "the sibling refresh token must never leak"
- );
- assert!(
- !raw.contains("leak-secret"),
- "the client secret must never leak"
- );
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("revoke-then-introspect");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("secret-ri"),
- )
- .await?;
- let minted = mint_bound(
- &mut h,
- &client_id,
- SenderProof {
- dpop_jkt: Some("jkt-revoked".to_owned()),
- mtls_x5t_s256: None,
- },
- )
- .await?;
- let outcome = {
- let service = token_service(&h)?;
- let auth = ClientAuthRequest {
- basic_header: None,
- client_id: Some(client_id.as_str()),
- body_secret: Some(SecretString::from("secret-ri")),
- assertion_type: None,
- assertion: None,
- peer_certs: None,
- };
- let mut stores = RevokeStores {
- pool: &h.pool,
- keys: &h.config.keys,
- conn: &mut h.conn,
- store: &h.store,
- service: &service,
- };
- let ctx = RevokeContext {
- token_endpoint: "https://auth.example.test/token",
- now: OffsetDateTime::now_utc(),
- cimd: None,
- request_id: Some("req-introspect-revoke"),
- };
- let request = RevokeRequest {
- token: &minted.access_token,
- token_type_hint: None,
- auth: &auth,
- };
- revoke_tokens(&mut stores, &ctx, &request, None).await?
- };
- assert_eq!(
- outcome,
- RevokeOutcome::Revoked,
- "the owner must revoke its own DPoP-bound token"
- );
- // RFC 7009 §2.2: after revocation the token is no longer usable, so
- // RFC 7662 §2.2 introspection reports the inactive shape.
- let response = run_core(
- &mut h,
- &minted.access_token,
- Some("access_token"),
- &client_id,
- "secret-ri",
- )
- .await?;
- assert!(!response.active, "a revoked token must not be active");
- assert_eq!(response, IntrospectionResponse::inactive());
- Ok(())
- }
- /// Mint one token pair owned by `client_id` with an explicit audience.
- async fn mint_audience(
- h: &mut Harness,
- client_id: &str,
- aud: &[&str],
- ) -> TestResult<crate::protocol::token::MintedTokens> {
- mint_audience_subject(h, client_id, aud, "user-1").await
- }
- /// Mint one token pair owned by `client_id` with an explicit audience and
- /// subject.
- async fn mint_audience_subject(
- h: &mut Harness,
- client_id: &str,
- aud: &[&str],
- subject: &str,
- ) -> TestResult<crate::protocol::token::MintedTokens> {
- let service = token_service(h)?;
- let aud: Vec<String> = aud.iter().map(|value| (*value).to_owned()).collect();
- Ok(
- service
- .mint(
- &mut h.conn,
- &h.store,
- &MintRequest {
- client_id: client_id.to_owned(),
- client_kind: ClientKind::Confidential,
- dpop_required: false,
- subject: subject.to_owned(),
- scope: "orders:read".to_owned(),
- access_scope: "orders:read".to_owned(),
- grant_id: unique("grant"),
- proof: SenderProof::none(),
- aud: aud.clone(),
- refresh_aud: aud,
- format: TokenFormat::Opaque,
- },
- now_unix(),
- )
- .await?,
- )
- }
- /// Mint one token pair owned by `client_id` with an explicit subject.
- async fn mint_subject(
- h: &mut Harness,
- client_id: &str,
- subject: &str,
- ) -> TestResult<crate::protocol::token::MintedTokens> {
- let service = token_service(h)?;
- Ok(
- service
- .mint(
- &mut h.conn,
- &h.store,
- &MintRequest {
- client_id: client_id.to_owned(),
- client_kind: ClientKind::Confidential,
- dpop_required: false,
- subject: subject.to_owned(),
- scope: "orders:read".to_owned(),
- access_scope: "orders:read".to_owned(),
- grant_id: unique("grant"),
- proof: SenderProof::none(),
- aud: Vec::new(),
- refresh_aud: Vec::new(),
- format: TokenFormat::Opaque,
- },
- now_unix(),
- )
- .await?,
- )
- }
- /// Provision one enabled user with a fast test password hash.
- async fn provision_user(h: &Harness, email: &str) -> TestResult<UserRecord> {
- let params = Argon2Params::new(8, 1, 1, 32).map_err(|error| format!("{error:?}"))?;
- Ok(
- user_repo::create_user(
- &h.pool,
- &h.config.keys,
- false,
- &NewUser {
- email: email.to_owned(),
- password_hash: hash_secret("correct-horse-01", ¶ms)?,
- is_admin: false,
- must_change_password: false,
- },
- )
- .await?,
- )
- }
- async fn introspect_client(h: &mut Harness, label: &str, secret: &str) -> TestResult<String> {
- let client_id = unique(label);
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some(secret),
- )
- .await?;
- Ok(client_id)
- }
- #[tokio::test]
- async fn test_introspect_disabled_email_subject_is_inactive() -> TestResult {
- let mut h = harness().await?;
- let client_id = introspect_client(&mut h, "disabled-email", "secret-de").await?;
- let user = provision_user(&h, "disabled-introspect@example.test").await?;
- let minted = mint_subject(&mut h, &client_id, &user.email).await?;
- assert!(user_repo::disable_user(&h.pool, user.id).await?);
- let response = run_core(
- &mut h,
- &minted.access_token,
- Some("access_token"),
- &client_id,
- "secret-de",
- )
- .await?;
- assert!(!response.active, "a disabled user's token must be inactive");
- assert_eq!(response, IntrospectionResponse::inactive());
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_disabled_uuid_subject_is_inactive() -> TestResult {
- let mut h = harness().await?;
- let client_id = introspect_client(&mut h, "disabled-uuid", "secret-du").await?;
- let user = provision_user(&h, "disabled-uuid@example.test").await?;
- // Device-code tokens carry the user UUID string as subject.
- let minted = mint_subject(&mut h, &client_id, &user.id.to_string()).await?;
- assert!(user_repo::disable_user(&h.pool, user.id).await?);
- let response = run_core(
- &mut h,
- &minted.access_token,
- Some("access_token"),
- &client_id,
- "secret-du",
- )
- .await?;
- assert!(!response.active, "a disabled user's token must be inactive");
- assert_eq!(response, IntrospectionResponse::inactive());
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_enabled_email_subject_stays_active() -> TestResult {
- let mut h = harness().await?;
- let client_id = introspect_client(&mut h, "enabled-email", "secret-ee").await?;
- let user = provision_user(&h, "enabled-introspect@example.test").await?;
- let minted = mint_subject(&mut h, &client_id, &user.email).await?;
- let response = run_core(
- &mut h,
- &minted.access_token,
- Some("access_token"),
- &client_id,
- "secret-ee",
- )
- .await?;
- assert!(response.active, "an enabled user's token must stay active");
- assert_eq!(response.sub.as_deref(), Some(user.email.as_str()));
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_reenabled_subject_is_active_again() -> TestResult {
- let mut h = harness().await?;
- let client_id = introspect_client(&mut h, "reenabled", "secret-re").await?;
- let user = provision_user(&h, "reenabled-introspect@example.test").await?;
- let minted = mint_subject(&mut h, &client_id, &user.email).await?;
- assert!(user_repo::disable_user(&h.pool, user.id).await?);
- let disabled = run_core(
- &mut h,
- &minted.access_token,
- Some("access_token"),
- &client_id,
- "secret-re",
- )
- .await?;
- assert!(!disabled.active, "a disabled user's token must be inactive");
- sqlx::query("UPDATE users SET disabled = false, updated_at = now() WHERE id = $1")
- .bind(user.id)
- .execute(&h.pool)
- .await?;
- let response = run_core(
- &mut h,
- &minted.access_token,
- Some("access_token"),
- &client_id,
- "secret-re",
- )
- .await?;
- assert!(
- response.active,
- "a re-enabled user's token must be active again"
- );
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_empty_and_unknown_subjects_stay_active() -> TestResult {
- let mut h = harness().await?;
- let client_id = introspect_client(&mut h, "non-user-sub", "secret-nu").await?;
- // Client-credentials tokens carry an empty subject.
- let empty = mint_subject(&mut h, &client_id, "").await?;
- let response = run_core(
- &mut h,
- &empty.access_token,
- Some("access_token"),
- &client_id,
- "secret-nu",
- )
- .await?;
- assert!(response.active, "an empty subject must stay active");
- // Synthetic subjects resolve to no user and must stay live.
- let synthetic = mint_subject(&mut h, &client_id, "synthetic-subject-9").await?;
- let response = run_core(
- &mut h,
- &synthetic.access_token,
- Some("access_token"),
- &client_id,
- "secret-nu",
- )
- .await?;
- assert!(response.active, "an unmapped subject must stay active");
- assert_eq!(response.sub.as_deref(), Some("synthetic-subject-9"));
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_enforces_requested_resource() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("resource-filter");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretBasic,
- Some("resource-secret"),
- )
- .await?;
- let audience_a = "https://a.introspect.test/";
- let audience_b = "https://b.introspect.test/";
- let audience_c = "https://c.introspect.test/";
- for identifier in [audience_a, audience_b, audience_c] {
- resource_repo::upsert(&h.pool, identifier, "introspect audience").await?;
- }
- let minted = mint_audience(&mut h, &client_id, &[audience_a, audience_b]).await?;
- let auth = basic_header(&client_id, "resource-secret");
- let access = &minted.access_token;
- // RFC 7662 defines no `resource` parameter; this server accepts it as a
- // local extension. A subset of the token audience stays active.
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!("token={access}&resource={audience_a}"),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(
- body.get("active").and_then(Value::as_bool),
- Some(true),
- "a requested audience contained in the token aud must stay active"
- );
- // Repeated values are a set: both members are present, order-insensitive.
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!("token={access}&resource={audience_a}&resource={audience_b}"),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(
- body.get("active").and_then(Value::as_bool),
- Some(true),
- "the complete repeated set must stay active"
- );
- // A requested audience the token is not bound to collapses to the inactive
- // shape: no scope, client_id, sub, cnf, or expiry may leak.
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!("token={access}&resource={audience_c}"),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(
- body,
- json!({"active": false}),
- "a mismatched audience must not report token metadata"
- );
- // Absent `resource` keeps RFC 7662 introspection audience-agnostic.
- let (status, body) = post_form(router(&h), Some(&auth), &format!("token={access}")).await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(
- body.get("active").and_then(Value::as_bool),
- Some(true),
- "no resource parameter must preserve today's behavior"
- );
- assert_eq!(
- body.get("scope").and_then(Value::as_str),
- Some("orders:read"),
- "an unfiltered active response keeps its metadata"
- );
- // The filter follows the token record, not the hint: the access token
- // resolves through the refresh hint (RFC 7662 §2.1 fallback) and stays
- // filtered, and the refresh token is filtered under its own hint.
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!("token={access}&token_type_hint=refresh_token&resource={audience_c}"),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(
- body,
- json!({"active": false}),
- "hint ordering must not bypass the audience filter"
- );
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!(
- "token={}&token_type_hint=refresh_token&resource={audience_a}",
- minted.refresh_token
- ),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(
- body.get("active").and_then(Value::as_bool),
- Some(true),
- "a refresh token bound to the requested audience must stay active"
- );
- // An unbound token (empty aud) cannot satisfy any requested audience.
- let empty = mint_audience(&mut h, &client_id, &[]).await?;
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!("token={}&resource={audience_a}", empty.access_token),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(
- body,
- json!({"active": false}),
- "an empty token audience must not satisfy a requested resource"
- );
- Ok(())
- }
- #[tokio::test]
- async fn test_introspect_reports_aud() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("aud-report");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretBasic,
- Some("secret-au"),
- )
- .await?;
- let audience_a = "https://a.aud-introspect.test/";
- let audience_b = "https://b.aud-introspect.test/";
- for identifier in [audience_a, audience_b] {
- resource_repo::upsert(&h.pool, identifier, "introspect audience").await?;
- }
- let auth = basic_header(&client_id, "secret-au");
- // RFC 9068 §2.2 array shape: an aud-bound token reports the exact stored
- // set as a JSON array, even though RFC 7662 §2.2 documents a string.
- let minted = mint_audience(&mut h, &client_id, &[audience_a, audience_b]).await?;
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!("token={}", minted.access_token),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(body.get("active").and_then(Value::as_bool), Some(true));
- assert_eq!(
- body.get("aud"),
- Some(&json!([audience_a, audience_b])),
- "the access token must report its exact stored audience set"
- );
- // A single audience is still a one-element array, never a bare string.
- let single = mint_audience(&mut h, &client_id, &[audience_a]).await?;
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!("token={}", single.access_token),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- let aud = body.get("aud").ok_or("aud member missing")?;
- assert!(aud.is_array(), "aud must be a JSON array, not a string");
- assert_eq!(aud, &json!([audience_a]));
- // The same rule applies to a refresh record that carries an audience.
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!(
- "token={}&token_type_hint=refresh_token",
- minted.refresh_token
- ),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(body.get("active").and_then(Value::as_bool), Some(true));
- assert_eq!(
- body.get("aud"),
- Some(&json!([audience_a, audience_b])),
- "a refresh record carrying an audience must report it"
- );
- // An unbound token omits the member entirely.
- let unbound = mint_audience(&mut h, &client_id, &[]).await?;
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!("token={}", unbound.access_token),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert!(body.get("aud").is_none(), "an unbound token must omit aud");
- // A disabled subject collapses to the RFC 7662 §2.2 inactive shape with no
- // audience leak, even when the record carries one.
- let user = provision_user(&h, "aud-disabled@example.test").await?;
- let disabled = mint_audience_subject(&mut h, &client_id, &[audience_a], &user.email).await?;
- assert!(user_repo::disable_user(&h.pool, user.id).await?);
- let (status, body) = post_form(
- router(&h),
- Some(&auth),
- &format!("token={}", disabled.access_token),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(body, json!({"active": false}));
- assert!(
- body.get("aud").is_none(),
- "an inactive response must not leak aud"
- );
- // An unknown token is inactive and carries no aud.
- let (status, body) = post_form(router(&h), Some(&auth), "token=not-a-real-aud-token").await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(body, json!({"active": false}));
- Ok(())
- }
- /// Mint one JWT access token owned by `client_id` through the configured
- /// signer (Todo 26 branch): the returned token verifies under the harness
- /// config, so the introspection JWT path must accept it.
- async fn mint_jwt(
- h: &mut Harness,
- client_id: &str,
- aud: &[&str],
- subject: &str,
- proof: SenderProof,
- ) -> TestResult<String> {
- let signer = JwtAccessSigner::from_config(&h.config)?;
- let service = token_service(h)?.with_jwt_signer(signer);
- let aud: Vec<String> = aud.iter().map(|value| (*value).to_owned()).collect();
- let minted = service
- .mint(
- &mut h.conn,
- &h.store,
- &MintRequest {
- client_id: client_id.to_owned(),
- client_kind: ClientKind::Confidential,
- dpop_required: false,
- subject: subject.to_owned(),
- scope: "orders:read".to_owned(),
- access_scope: "orders:read".to_owned(),
- grant_id: unique("grant"),
- proof,
- aud: aud.clone(),
- refresh_aud: aud,
- format: TokenFormat::Jwt,
- },
- now_unix(),
- )
- .await?;
- if minted.access_token.split('.').count() != 3 {
- return Err("a jwt mint must return three JOSE segments".into());
- }
- Ok(minted.access_token)
- }
- /// Flip the first signature character so the token still parses but the
- /// signature breaks (flipping the last char trips base64 padding instead).
- fn tamper_signature(token: &str) -> String {
- let mut parts: Vec<String> = token.split('.').map(str::to_owned).collect();
- let sig = parts[2].clone();
- let first = sig.chars().next().unwrap_or('a');
- let replacement = if first == 'a' { 'b' } else { 'a' };
- parts[2] = format!("{replacement}{}", &sig[1..]);
- parts.join(".")
- }
- #[tokio::test]
- async fn test_introspect_dual_path() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("dual-owner");
- insert_client(
- &h.pool,
- &h.config.keys,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("secret-dual"),
- )
- .await?;
- let foreign = unique("dual-foreign");
- insert_client(
- &h.pool,
- &h.config.keys,
- &foreign,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("secret-foreign"),
- )
- .await?;
- let audience_a = "https://a.dual-introspect.test/";
- let audience_b = "https://b.dual-introspect.test/";
- let audience_c = "https://c.dual-introspect.test/";
- for identifier in [audience_a, audience_b, audience_c] {
- resource_repo::upsert(&h.pool, identifier, "dual audience").await?;
- }
- // Opaque regression: an opaque access token stays active with its stored
- // aud, and a refresh hint still falls back to the access record.
- let opaque = mint_audience_subject(&mut h, &client_id, &[audience_a], "opaque-sub").await?;
- let response = run_core_full(
- &mut h,
- &opaque.access_token,
- None,
- &client_id,
- "secret-dual",
- &[],
- true,
- )
- .await?;
- assert!(response.active, "an opaque token must stay active");
- assert_eq!(response.token_type.as_deref(), Some("Bearer"));
- assert_eq!(response.aud, Some(vec![audience_a.to_owned()]));
- let hinted = run_core_full(
- &mut h,
- &opaque.access_token,
- Some("refresh_token"),
- &client_id,
- "secret-dual",
- &[],
- true,
- )
- .await?;
- assert!(
- hinted.active,
- "an opaque token under a refresh hint must still resolve"
- );
- assert_eq!(hinted.token_type.as_deref(), Some("Bearer"));
- // JWT: active with aud, token_type, client_id, sub, scope, exp/iat, cnf,
- // and no leaked JOSE internals.
- let jwt = mint_jwt(
- &mut h,
- &client_id,
- &[audience_a, audience_b],
- "jwt-subject-1",
- SenderProof {
- dpop_jkt: Some("dual-jkt".to_owned()),
- mtls_x5t_s256: None,
- },
- )
- .await?;
- let before = now_unix();
- let response = run_core_full(&mut h, &jwt, None, &client_id, "secret-dual", &[], true).await?;
- assert!(response.active, "a live JWT must introspect active");
- assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
- assert_eq!(response.scope.as_deref(), Some("orders:read"));
- assert_eq!(response.sub.as_deref(), Some("jwt-subject-1"));
- assert_eq!(response.token_type.as_deref(), Some("DPoP"));
- assert_eq!(
- response.aud,
- Some(vec![audience_a.to_owned(), audience_b.to_owned()])
- );
- let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
- assert_eq!(cnf.jkt.as_deref(), Some("dual-jkt"));
- let iat = response.iat.ok_or("iat missing")?;
- let exp = response.exp.ok_or("exp missing")?;
- assert!(exp > before, "exp must be in the future");
- let ttl = i64::try_from(h.config.ttl.access.as_secs())?;
- assert_eq!(exp - iat, ttl, "the JWT window rides the claims");
- assert_eq!(response.act, None, "a JWT carries no sidecar act");
- let json = serde_json::to_value(&response)?;
- assert!(
- json.get("jti").is_none(),
- "the allowlist jti must never leak into the response"
- );
- assert!(
- !serde_json::to_string(&json)?.contains(&jwt),
- "the presented JWT must never be echoed"
- );
- // The hint is advisory for opaque lookups and irrelevant for JWTs: a
- // refresh hint must not change the JWT verdict.
- let hinted_jwt = run_core_full(
- &mut h,
- &jwt,
- Some("refresh_token"),
- &client_id,
- "secret-dual",
- &[],
- true,
- )
- .await?;
- assert!(
- hinted_jwt.active,
- "a token_type_hint must not change the JWT path"
- );
- // A JWT owned by another client collapses to the same inactive shape as
- // an unknown token (RFC 7662 §2.2 indistinguishability).
- let foreign_response =
- run_core_full(&mut h, &jwt, None, &foreign, "secret-foreign", &[], true).await?;
- assert!(
- !foreign_response.active,
- "a cross-client JWT must be inactive"
- );
- assert_eq!(foreign_response, IntrospectionResponse::inactive());
- // A tampered signature, an unknown marker (signed but never minted), and
- // an expired JWT are all inactive, never errors.
- let tampered = tamper_signature(&jwt);
- let response = run_core_full(
- &mut h,
- &tampered,
- None,
- &client_id,
- "secret-dual",
- &[],
- true,
- )
- .await?;
- assert!(!response.active, "a tampered JWT must be inactive");
- let signer = JwtAccessSigner::from_config(&h.config)?;
- let ghost = signer.sign(&JwtClaims {
- iss: h.config.issuer.clone(),
- sub: "ghost-subject".to_owned(),
- aud: vec![audience_a.to_owned()],
- scope: "orders:read".to_owned(),
- exp: now_unix() + 3_600,
- iat: now_unix(),
- jti: unique("ghost-jti"),
- client_id: client_id.clone(),
- cnf: None,
- act: None,
- })?;
- let response = run_core_full(&mut h, &ghost, None, &client_id, "secret-dual", &[], true).await?;
- assert!(!response.active, "a JWT with no marker must be inactive");
- let stale = signer.sign(&JwtClaims {
- iss: h.config.issuer.clone(),
- sub: "ghost-subject".to_owned(),
- aud: vec![audience_a.to_owned()],
- scope: "orders:read".to_owned(),
- exp: now_unix() - 10,
- iat: now_unix() - 3_610,
- jti: unique("stale-jti"),
- client_id: client_id.clone(),
- cnf: None,
- act: None,
- })?;
- let response = run_core_full(&mut h, &stale, None, &client_id, "secret-dual", &[], true).await?;
- assert!(!response.active, "an expired JWT must be inactive");
- // A disabled subject renders the live JWT inactive with no aud leak.
- let user = provision_user(&h, "dual-disabled@example.test").await?;
- let bound = mint_jwt(
- &mut h,
- &client_id,
- &[audience_a],
- &user.email,
- SenderProof::none(),
- )
- .await?;
- assert!(user_repo::disable_user(&h.pool, user.id).await?);
- let response = run_core_full(&mut h, &bound, None, &client_id, "secret-dual", &[], true).await?;
- assert!(
- !response.active,
- "a disabled subject's JWT must be inactive"
- );
- assert_eq!(response, IntrospectionResponse::inactive());
- // Flag off: the same live JWT introspects inactive (never 5xx). The core
- // returns `Ok`, so reaching this line already proves no error was raised.
- let response = run_core_full(&mut h, &jwt, None, &client_id, "secret-dual", &[], false).await?;
- assert!(!response.active, "a JWT with the flag off must be inactive");
- assert_eq!(response, IntrospectionResponse::inactive());
- // Request-`resource` parity over HTTP (handler validation included): a
- // requested audience the JWT lacks is inactive; one it has stays active.
- // The fixture client is `ClientSecretPost`, so the HTTP calls carry the
- // credentials in the body (a Basic header against a post client is 401).
- let credentials = format!("client_id={client_id}&client_secret=secret-dual");
- let (status, body) = post_form(
- router(&h),
- None,
- &format!("token={jwt}&resource={audience_c}&{credentials}"),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(
- body,
- json!({"active": false}),
- "a JWT missing the requested resource must be inactive"
- );
- let (status, body) = post_form(
- router(&h),
- None,
- &format!("token={jwt}&resource={audience_a}&{credentials}"),
- )
- .await?;
- assert_eq!(status, StatusCode::OK);
- assert_eq!(
- body.get("active").and_then(Value::as_bool),
- Some(true),
- "a JWT carrying the requested resource must stay active"
- );
- Ok(())
- }
|