From 454fc33b8edb6b6b21fc8af990d6c3a309e30daa Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 21:37:01 +0200 Subject: [PATCH] Check Heimdall's session expiry against its refresh expiry, not access Heimdall's session record lives as long as its refresh token: issueAccessClaim reports session.expiresAt and refresh.expiresAt as the record's expiry, and only the signed access claim carries the shorter access expiry. Dungeon required the session expiry to equal the access claim's exp, so every completion Heimdall redeemed was refused ("session expiry disagrees with its signed claim") and no session was ever adopted. The session expiry must now equal the refresh expiry, and refresh must outlive the signed access claim. The new test builds the completion in the shape Heimdall issues; restoring the old comparison fails it. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-dungeon/src/heimdall.rs | 87 ++++++++++++++++++++++- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/ghostlight-dungeon/src/heimdall.rs b/crates/ghostlight-dungeon/src/heimdall.rs index cc9b7f7..01345f6 100644 --- a/crates/ghostlight-dungeon/src/heimdall.rs +++ b/crates/ghostlight-dungeon/src/heimdall.rs @@ -660,13 +660,15 @@ fn verified_session_material( let access_expiry = i64::try_from(claims.exp).context("Heimdall access expiry is too large")?; let access_expires_at = DateTime::from_timestamp(access_expiry, 0).context("Heimdall access expiry is invalid")?; + // Heimdall's session record lives as long as its refresh token, so the + // session expiry it reports is the refresh expiry (Heimdall + // `issueAccessClaim`: `session.expiresAt` and `refresh.expiresAt` are both + // the record's `expiresAt`); only the signed access claim carries the + // shorter access expiry. let session_expires_at: DateTime = session .expires_at .parse() .context("Heimdall session expiry is invalid")?; - if session_expires_at != access_expires_at { - bail!("Heimdall session expiry disagrees with its signed claim"); - } let refresh_expires_at: DateTime = completion .refresh .as_ref() @@ -674,6 +676,9 @@ fn verified_session_material( .expires_at .parse() .context("Heimdall refresh expiry is invalid")?; + if session_expires_at != refresh_expires_at { + bail!("Heimdall session expiry disagrees with its refresh expiry"); + } if refresh_expires_at <= access_expires_at { bail!("Heimdall refresh expiry does not outlive access"); } @@ -1227,4 +1232,80 @@ mod tests { .contains("omitted the account") ); } + + /// The shape Heimdall's `issueAccessClaim` returns: the access claim + /// expires first; session and refresh share the longer record expiry. + fn issued_completion(session_expiry: &str, refresh_expiry: &str) -> AuthCompletionReceipt { + AuthCompletionReceipt { + status: "authenticated".into(), + handle: None, + error: None, + account: None, + session: Some(HeimdallSession { + account_id: "account-1".into(), + session_id: "session-1".into(), + app_slug: APP_SLUG.into(), + access_revision: 1, + expires_at: session_expiry.into(), + }), + access_token: Some("private".into()), + refresh_token: Some("refresh".into()), + refresh: Some(RefreshSummary { + expires_at: refresh_expiry.into(), + }), + shared_capabilities: vec!["app_access".into()], + } + } + + fn access_claims(exp: u64) -> AccessClaims { + AccessClaims { + iss: "https://heimdall.invalid".into(), + aud: APP_SLUG.into(), + sub: "account-1".into(), + sid: "session-1".into(), + exp, + typ: "heimdall_access".into(), + account_id: "account-1".into(), + access_revision: 1, + capabilities: vec!["app_access".into()], + app: AppClaim { + slug: APP_SLUG.into(), + }, + } + } + + #[test] + fn a_session_as_heimdall_issues_it_is_admitted() { + // Access expires at 2026-09-23T20:00:00Z; the session record and its + // refresh token last thirty days. + let access_exp = 1_790_193_600; + let material = verified_session_material( + issued_completion("2026-10-23T20:00:00.000Z", "2026-10-23T20:00:00.000Z"), + access_claims(access_exp), + ) + .expect("Heimdall's issued session should be admitted"); + assert_eq!(material.access_expires_at.timestamp(), access_exp as i64); + assert!(material.refresh_expires_at > material.access_expires_at); + + assert!( + verified_session_material( + issued_completion("2026-10-22T20:00:00.000Z", "2026-10-23T20:00:00.000Z"), + access_claims(access_exp), + ) + .err() + .expect("Heimdall shape should be refused") + .to_string() + .contains("disagrees with its refresh expiry") + ); + assert!( + verified_session_material( + issued_completion("2026-09-23T19:00:00.000Z", "2026-09-23T19:00:00.000Z"), + access_claims(access_exp), + ) + .err() + .expect("Heimdall shape should be refused") + .to_string() + .contains("does not outlive access") + ); + } }