test(08-06): add failing refresh-lifecycle RED anchor and store interface

TestPhase8RedLifecycleFramework drives exchange -> rotate -> replay against
the real (in-memory-backed) Server.Token and fails while rotateRefreshToken
is 08-04's invalid_grant placeholder (PHASE8_RED:lifecycle-framework,
verified fail-closed via scripts/check-phase8-red.sh). Extends the
RefreshTokenStore/AuthCodeStore interfaces with the store seams Task 2's
implementation needs (ByAPITokenIDForUpdate, MarkRotated,
DeleteExpiredCodes, DeleteExpiredRefreshTokens) and updates the framework's
in-memory test double to satisfy them.
This commit is contained in:
Jakub Zych
2026-09-23 21:26:05 +02:00
parent da01ea105c
commit b2c2cc0bb7
4 changed files with 545 additions and 18 deletions

View File

@@ -112,6 +112,12 @@ type AuthCodeStore interface {
// ones), and extends ExpiresAt to the fresh code TTL.
MarkIssued(ctx context.Context, id uint, codeHash string, userID uint, scopes []string, collectionIDs []uint, expiresAt time.Time) error
MarkUsed(ctx context.Context, id uint) error
// DeleteExpiredCodes removes pending and issued authorization-code rows
// whose ExpiresAt is before now (D-17: wristband adds an expiry sweep
// PHP lacks). used_at/request_id status is irrelevant to the decision:
// only expiry drives deletion, so unexpired issued-but-unused rows and
// unexpired used rows both survive untouched.
DeleteExpiredCodes(ctx context.Context, now time.Time) error
}
// RefreshTokenStore persists refresh-token lineage rows. ByTokenHashForUpdate
@@ -120,7 +126,29 @@ type AuthCodeStore interface {
type RefreshTokenStore interface {
Create(ctx context.Context, rec *RefreshTokenRecord) error
ByTokenHashForUpdate(ctx context.Context, tokenHash string) (*RefreshTokenRecord, error)
// ByAPITokenIDForUpdate row-locks the refresh row currently linked to
// apiTokenID, if any (08-06-PLAN.md D-08). It is the seam a
// connected-app revoke uses to find the lineage to kill without
// wristband inventing its own SQL join; a nil result (no linked
// refresh row) is not an error.
ByAPITokenIDForUpdate(ctx context.Context, apiTokenID uint) (*RefreshTokenRecord, error)
// MarkRotated links a spent-by-rotation predecessor to its successor
// (PHP OAuthCodeManager::rotateRefresh's `$record->rotated_to_id =
// $successor->id`). The predecessor's own RevokedAt stays nil: a
// rotated-but-not-yet-replayed row remains retrievable as replay
// evidence (D-17); "already rotated" is signaled by RotatedToID, not
// RevokedAt.
MarkRotated(ctx context.Context, id uint, successorID uint) error
// RevokeLineage stamps RevokedAt on startID and every row it was
// rotated to (walking forward through RotatedToID), and revokes each
// visited row's linked access token too (T-08-REFRESH-REPLAY). Rows
// are not deleted: unexpired revoked rows stay as replay evidence
// (D-17).
RevokeLineage(ctx context.Context, startID uint) error
// DeleteExpiredRefreshTokens removes refresh rows whose ExpiresAt is
// before now (D-17). Revoked/rotated-but-unexpired rows are untouched
// so replay detection and connected-apps history stay correct.
DeleteExpiredRefreshTokens(ctx context.Context, now time.Time) error
}
// AccessTokenIssuer mints/revokes the app's ordinary personal access token