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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-23 21:37:01 +02:00
commit 454fc33b8e

View file

@ -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<Utc> = 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<Utc> = 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")
);
}
}