Blank the consumed refresh token in the fallback marker write
When the rotation or revocation the provider's answer called for cannot be committed, the fallback write now clears refresh_token_encrypted as well as stamping the marker, so the end of the 60 s backoff cannot replay the consumed token. Tests (live Postgres, trigger-refused revoke) assert no replayable token and no second provider call. Docs: the marker is a best-effort write on a second connection after rollback, the skew cap, the blanking write's residual. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9c7d980165
commit
f2f1dabc06
3 changed files with 27 additions and 9 deletions
|
|
@ -572,11 +572,22 @@ Important behavior:
|
|||
(`refresh_failed_at`, written in the same transaction as the lock), and no
|
||||
process presents that row's refresh token again for 60 seconds: a process
|
||||
that takes the lock inside the backoff answers the same
|
||||
`managed_credential_refresh_failed` without calling the provider. A rotation,
|
||||
`managed_credential_refresh_failed` without calling the provider. A stamp
|
||||
more than 60 seconds in the future (a writer whose clock runs ahead) is skew,
|
||||
not a fresh failure, so the wait is capped at twice the backoff. A rotation,
|
||||
a revocation or a re-link clears the marker.
|
||||
The marker is a best-effort write on a second connection, made after the
|
||||
lock's transaction rolled back; it is not part of that transaction. When the
|
||||
rotation or the revocation the provider's answer called for cannot be
|
||||
committed (a rotating provider answered without a refresh token, so the
|
||||
revoke was due, and the write was refused), the fallback write also blanks
|
||||
the refresh token the provider consumed, so the end of the backoff does not
|
||||
present it again (`joystick:follow_up:chat-s4509-4511`, steward ruling in
|
||||
#minds, 2026-11-09).
|
||||
Residual risk: the provider may consume the refresh token as soon as the
|
||||
request is sent. If a Heimdall process dies after that and before the commit
|
||||
of the marker, the rotation or the revocation, nothing is recorded, and the
|
||||
of the marker, the rotation or the revocation, or if the blanking fallback
|
||||
write fails too, nothing is recorded, and the
|
||||
next caller can present the refresh token the provider already consumed; for
|
||||
a provider that rotates (Joystick) that reads as token theft. The Heimdall
|
||||
steward ruled (#minds, 2026-11-08) that an intent log, written on its own
|
||||
|
|
|
|||
|
|
@ -867,10 +867,11 @@ export class PostgresStore implements HeimdallStore {
|
|||
if (decision?.kind === "rotate" || decision?.kind === "revoke") {
|
||||
// The provider answered and may have consumed the refresh token, and the rotation or the revoke its answer
|
||||
// called for (a rotating provider's missing refresh token) could not be committed: the rollback took the
|
||||
// answer with it. Leave the marker in a transaction of its own, so the next caller does not
|
||||
// present the old token. Best effort: if this write fails too, the original error is the one that matters.
|
||||
// answer with it. In a transaction of its own, blank the consumed token and leave the marker, so no later
|
||||
// caller, after the backoff either, presents it again. Best effort: if this write fails too, the original
|
||||
// error is the one that matters, and the residual is named in docs/service-contract.md.
|
||||
await client
|
||||
.query("UPDATE linked_identities SET refresh_failed_at = $2 WHERE id = $1", [id, decision.updatedAt])
|
||||
.query("UPDATE linked_identities SET refresh_token_encrypted = NULL, refresh_failed_at = $2 WHERE id = $1", [id, decision.updatedAt])
|
||||
.catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -599,9 +599,12 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
expect(endpoint.calls).toBe(1);
|
||||
const marked = await rig.row();
|
||||
expect(marked.refreshFailedAt).toBeDefined();
|
||||
expect(marked.refreshTokenEncrypted).toBe(snapshot.refreshTokenEncrypted);
|
||||
// The provider consumed the token: nothing replayable is left on the row, so the backoff ending presents nothing.
|
||||
expect(marked.refreshTokenEncrypted).toBeUndefined();
|
||||
|
||||
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!), snapshot)).resolves.toBeDefined();
|
||||
const afterBackoff = { ...ctx(rig.stores[0]!), clock: () => Date.now() + 5 * 60_000 };
|
||||
await expect(refreshManagedCredential(afterBackoff, snapshot)).resolves.toBeDefined();
|
||||
expect(endpoint.calls).toBe(1);
|
||||
} finally {
|
||||
await db.end();
|
||||
|
|
@ -682,9 +685,12 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
expect(endpoint.calls).toBe(1);
|
||||
const marked = await rig.row();
|
||||
expect(marked.refreshFailedAt).toBeDefined();
|
||||
expect(marked.refreshTokenEncrypted).toBe(snapshot.refreshTokenEncrypted);
|
||||
// The provider consumed the token: nothing replayable is left on the row, so the backoff ending presents nothing.
|
||||
expect(marked.refreshTokenEncrypted).toBeUndefined();
|
||||
|
||||
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!), snapshot)).resolves.toBeDefined();
|
||||
const afterBackoff = { ...ctx(rig.stores[0]!), clock: () => Date.now() + 5 * 60_000 };
|
||||
await expect(refreshManagedCredential(afterBackoff, snapshot)).resolves.toBeDefined();
|
||||
expect(endpoint.calls).toBe(1);
|
||||
} finally {
|
||||
await db.end();
|
||||
|
|
|
|||
Loading…
Reference in a new issue