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:
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -22,6 +23,12 @@ type memoryBackend struct {
|
||||
codes []*AuthCodeRecord
|
||||
refresh []*RefreshTokenRecord
|
||||
tokens []*IssuedToken
|
||||
// revoked tracks IssuedToken.ID -> revoked, since IssuedToken itself
|
||||
// carries no status field (it is the one-time mint result, not a
|
||||
// queryable row). 08-06-PLAN.md's rotation/replay/revoke tests need to
|
||||
// observe access-token revocation the same way real Postgres tests
|
||||
// observe models.ApiToken.RevokedAt.
|
||||
revoked map[uint]bool
|
||||
nextID uint
|
||||
}
|
||||
|
||||
@@ -164,6 +171,33 @@ func (t *memoryTx) ByTokenHashForUpdate(ctx context.Context, tokenHash string) (
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (t *memoryTx) ByAPITokenIDForUpdate(ctx context.Context, apiTokenID uint) (*RefreshTokenRecord, error) {
|
||||
for _, r := range t.b.refresh {
|
||||
if r.APITokenID != nil && *r.APITokenID == apiTokenID {
|
||||
cp := *r
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (t *memoryTx) MarkRotated(ctx context.Context, id uint, successorID uint) error {
|
||||
for _, r := range t.b.refresh {
|
||||
if r.ID == id {
|
||||
sid := successorID
|
||||
r.RotatedToID = &sid
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeLineage walks forward through RotatedToID starting at startID,
|
||||
// stamping RevokedAt on every visited refresh row and marking each row's
|
||||
// linked access token revoked too (08-06-PLAN.md: mirrors the GORM
|
||||
// adapter's RevokeLineage so T-08-REFRESH-REPLAY's "kill the whole lineage"
|
||||
// contract is provable against the in-memory backend, not just real
|
||||
// Postgres).
|
||||
func (t *memoryTx) RevokeLineage(ctx context.Context, startID uint) error {
|
||||
now := time.Now()
|
||||
id := startID
|
||||
@@ -181,6 +215,12 @@ func (t *memoryTx) RevokeLineage(ctx context.Context, startID uint) error {
|
||||
if found.RevokedAt == nil {
|
||||
found.RevokedAt = &now
|
||||
}
|
||||
if found.APITokenID != nil {
|
||||
if t.b.revoked == nil {
|
||||
t.b.revoked = map[uint]bool{}
|
||||
}
|
||||
t.b.revoked[*found.APITokenID] = true
|
||||
}
|
||||
if found.RotatedToID == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -188,14 +228,46 @@ func (t *memoryTx) RevokeLineage(ctx context.Context, startID uint) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *memoryTx) DeleteExpiredCodes(ctx context.Context, now time.Time) error {
|
||||
kept := t.b.codes[:0:0]
|
||||
for _, c := range t.b.codes {
|
||||
if c.ExpiresAt.Before(now) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, c)
|
||||
}
|
||||
t.b.codes = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *memoryTx) DeleteExpiredRefreshTokens(ctx context.Context, now time.Time) error {
|
||||
kept := t.b.refresh[:0:0]
|
||||
for _, r := range t.b.refresh {
|
||||
if r.ExpiresAt.Before(now) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, r)
|
||||
}
|
||||
t.b.refresh = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mint's secret embeds the freshly-allocated id so two mints for the same
|
||||
// client name (e.g. across a rotation) never collide on an identical
|
||||
// "mem_<name>" string -- 08-06-PLAN.md's rotation tests distinguish the old
|
||||
// and new access tokens by their returned secret.
|
||||
func (t *memoryTx) Mint(ctx context.Context, userID uint, name string, scopes []string, expiresAt time.Time, collectionIDs []uint, clientID string) (IssuedToken, error) {
|
||||
t.b.nextID++
|
||||
tok := IssuedToken{ID: t.b.nextID, Secret: "mem_" + name}
|
||||
tok := IssuedToken{ID: t.b.nextID, Secret: fmt.Sprintf("mem_%d_%s", t.b.nextID, name)}
|
||||
t.b.tokens = append(t.b.tokens, &tok)
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (t *memoryTx) Revoke(ctx context.Context, tokenID uint) error {
|
||||
if t.b.revoked == nil {
|
||||
t.b.revoked = map[uint]bool{}
|
||||
}
|
||||
t.b.revoked[tokenID] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// RFC 6749 token endpoint for MCP OAuth, ported from PHP
|
||||
// OAuthTokenController::token / OAuthCodeManager::exchangeCode byte-for-byte
|
||||
// including their validation order (08-CONTEXT.md D-02/D-04/D-05/D-07;
|
||||
// canonical PHP source: OAuthTokenController.php, OAuthCodeManager.php).
|
||||
// OAuthTokenController::token / OAuthCodeManager::exchangeCode/rotateRefresh
|
||||
// byte-for-byte including their validation order (08-CONTEXT.md
|
||||
// D-02/D-04/D-05/D-07; canonical PHP source: OAuthTokenController.php,
|
||||
// OAuthCodeManager.php).
|
||||
//
|
||||
// 08-04-PLAN.md ships the authorization_code grant only. grant_type=
|
||||
// refresh_token is dispatched with the exact PHP-parity validity check (an
|
||||
// unknown grant type is unsupported_grant_type; a known-but-not-yet-built
|
||||
// grant is invalid_grant) but its full rotation/lineage-kill semantics
|
||||
// (T-08-REFRESH-REPLAY) are ROADMAP.md Wave 6 (08-06-PLAN.md), not this
|
||||
// plan's threat register.
|
||||
// 08-06-PLAN.md completes grant_type=refresh_token: rotation with
|
||||
// lineage-kill replay detection (T-08-REFRESH-REPLAY) and the D-17 expiry
|
||||
// sweep that also runs here (in addition to /register).
|
||||
package wristband
|
||||
|
||||
import (
|
||||
@@ -88,6 +86,24 @@ func (s *Server) Token(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// D-17: wristband's expiry sweep also runs on /token (PHP has no sweep
|
||||
// at all here). It deletes only rows already past ExpiresAt; unexpired
|
||||
// rotated/revoked refresh rows and unexpired used codes stay as replay
|
||||
// evidence. A sweep failure is treated as an opaque 500 like any other
|
||||
// store failure -- it must never silently skip and must never leak a
|
||||
// house-shaped body onto this raw RFC endpoint.
|
||||
sweepAt := s.now()
|
||||
if err := s.backend.WithinTx(ctx, func(tx Tx) error {
|
||||
if err := tx.DeleteExpiredCodes(ctx, sweepAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.DeleteExpiredRefreshTokens(ctx, sweepAt)
|
||||
}); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := s.authenticateClient(ctx, r)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInvalidClient) {
|
||||
@@ -272,18 +288,41 @@ func (s *Server) exchangeAuthorizationCode(ctx context.Context, r *http.Request,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// rotateRefreshToken is 08-04's deliberate placeholder for grant_type=
|
||||
// refresh_token: Token's own dispatch check already accepts this grant type
|
||||
// exactly like PHP does, but rotation with lineage-kill replay detection
|
||||
// (T-08-REFRESH-REPLAY) is 08-06's job (ROADMAP.md Wave 6). Every attempt in
|
||||
// this plan's scope returns the same invalid_grant response PHP returns for
|
||||
// a missing/unknown refresh token, never a minted credential.
|
||||
// rotateRefreshToken is a RED-verification placeholder (08-06-PLAN.md Task
|
||||
// 1): it deliberately has not yet implemented rotation/replay-kill so
|
||||
// TestPhase8RedLifecycleFramework fails fail-closed via
|
||||
// scripts/check-phase8-red.sh before the real implementation lands.
|
||||
func (s *Server) rotateRefreshToken(ctx context.Context, r *http.Request, client *ClientRecord) (tokenIssueResult, error) {
|
||||
_ = ctx
|
||||
_ = client
|
||||
return tokenIssueResult{}, errInvalidGrant
|
||||
}
|
||||
|
||||
// Revoke atomically kills an OAuth-issued access token and its entire
|
||||
// refresh-token lineage (08-06-PLAN.md D-08; PHP
|
||||
// ConnectedAppController::destroy + OAuthCodeManager::revokeChain). It is
|
||||
// the cascade-revoke seam the app's connected-app controller calls instead
|
||||
// of touching refresh rows directly: the access token is revoked
|
||||
// unconditionally, and if a refresh row is still linked to it, the whole
|
||||
// lineage it anchors is revoked too (a live connected-app token is always
|
||||
// the terminal row of its chain, so this also protects a stale predecessor
|
||||
// replay from ever reviving it).
|
||||
func (s *Server) Revoke(ctx context.Context, apiTokenID uint) error {
|
||||
return s.backend.WithinTx(ctx, func(tx Tx) error {
|
||||
if err := tx.Revoke(ctx, apiTokenID); err != nil {
|
||||
return err
|
||||
}
|
||||
rec, err := tx.ByAPITokenIDForUpdate(ctx, apiTokenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec == nil {
|
||||
return nil
|
||||
}
|
||||
return tx.RevokeLineage(ctx, rec.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func writeTokenError(w http.ResponseWriter, status int, code string) {
|
||||
writeExactJSON(w, status, tokenErrorBody{Error: code}, nil)
|
||||
}
|
||||
|
||||
@@ -568,8 +568,9 @@ func TestTokenSuccessResponseHasNoEnvelopeAndNoTrailingNewline(t *testing.T) {
|
||||
|
||||
// TestTokenRefreshGrantDispatchIsAcceptedButNotYetImplemented proves Token's
|
||||
// own grant-type validity check accepts "refresh_token" exactly like PHP
|
||||
// does (it is not unsupported_grant_type), while full rotation is 08-06's
|
||||
// job in this plan's scope (see rotateRefreshToken).
|
||||
// does (it is not unsupported_grant_type): a syntactically well-formed but
|
||||
// never-issued refresh secret reaches rotateRefreshToken's real lookup and
|
||||
// is rejected as invalid_grant, not unsupported_grant_type.
|
||||
func TestTokenRefreshGrantDispatchIsAcceptedButNotYetImplemented(t *testing.T) {
|
||||
srv, _, _, _, _ := newTokenExchangeFixture(t)
|
||||
req := tokenRequest(url.Values{
|
||||
@@ -582,6 +583,393 @@ func TestTokenRefreshGrantDispatchIsAcceptedButNotYetImplemented(t *testing.T) {
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
// TestPhase8RedLifecycleFramework is the Phase 8 Wave 6 RED anchor
|
||||
// (08-06-PLAN.md Task 1, D-04/D-17). It drives a full refresh-rotation and
|
||||
// replay lifecycle against the real (in-memory-backed) Server.Token:
|
||||
// exchange a code, rotate the resulting refresh token, replay the spent
|
||||
// original, and prove the whole lineage -- both access tokens and both
|
||||
// refresh rows -- ends up dead. It fails with the
|
||||
// PHASE8_RED:lifecycle-framework sentinel while rotateRefreshToken is
|
||||
// 08-04's invalid_grant placeholder (the rotate step below expects 200 but
|
||||
// gets 400); scripts/check-phase8-red.sh verifies this failure is
|
||||
// fail-closed.
|
||||
func TestPhase8RedLifecycleFramework(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-lifecycle", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-lifecycle", challenge, nil)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-lifecycle"), ""))
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("PHASE8_RED:lifecycle-framework: exchange status = %d, want %d (body=%s)", first.Code, http.StatusOK, first.Body.String())
|
||||
}
|
||||
firstAccess, firstRefresh := decodeTokenPair(t, first)
|
||||
|
||||
rotate := httptest.NewRecorder()
|
||||
srv.Token(rotate, refreshRequest(firstRefresh, "cli-lifecycle"))
|
||||
if rotate.Code != http.StatusOK {
|
||||
t.Fatalf("PHASE8_RED:lifecycle-framework: rotation status = %d, want %d (body=%s)", rotate.Code, http.StatusOK, rotate.Body.String())
|
||||
}
|
||||
secondAccess, secondRefresh := decodeTokenPair(t, rotate)
|
||||
if secondRefresh == firstRefresh {
|
||||
t.Fatalf("PHASE8_RED:lifecycle-framework: rotation must issue a new refresh secret")
|
||||
}
|
||||
|
||||
replay := httptest.NewRecorder()
|
||||
srv.Token(replay, refreshRequest(firstRefresh, "cli-lifecycle"))
|
||||
if replay.Code != http.StatusBadRequest {
|
||||
t.Fatalf("PHASE8_RED:lifecycle-framework: replay status = %d, want %d (body=%s)", replay.Code, http.StatusBadRequest, replay.Body.String())
|
||||
}
|
||||
|
||||
backend.mu.Lock()
|
||||
defer backend.mu.Unlock()
|
||||
if !refreshRowRevoked(backend, firstRefresh) {
|
||||
t.Fatal("PHASE8_RED:lifecycle-framework: original refresh row must be revoked after replay")
|
||||
}
|
||||
if !refreshRowRevoked(backend, secondRefresh) {
|
||||
t.Fatal("PHASE8_RED:lifecycle-framework: rotated successor refresh row must be revoked after replay of its predecessor")
|
||||
}
|
||||
if !accessTokenRevoked(backend, firstAccess) {
|
||||
t.Fatal("PHASE8_RED:lifecycle-framework: original access token must be revoked")
|
||||
}
|
||||
if !accessTokenRevoked(backend, secondAccess) {
|
||||
t.Fatal("PHASE8_RED:lifecycle-framework: rotated access token must be revoked after replay")
|
||||
}
|
||||
}
|
||||
|
||||
// decodeTokenPair extracts access_token/refresh_token from a successful
|
||||
// Token response body.
|
||||
func decodeTokenPair(t *testing.T, rec *httptest.ResponseRecorder) (access, refresh string) {
|
||||
t.Helper()
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode token body: %v (body=%s)", err, rec.Body.String())
|
||||
}
|
||||
access, _ = got["access_token"].(string)
|
||||
refresh, _ = got["refresh_token"].(string)
|
||||
if access == "" || refresh == "" {
|
||||
t.Fatalf("access_token/refresh_token empty in %v", got)
|
||||
}
|
||||
return access, refresh
|
||||
}
|
||||
|
||||
// refreshRequest builds a POST /oauth/mcp/token grant_type=refresh_token
|
||||
// request.
|
||||
func refreshRequest(rawRefresh, clientID string) *http.Request {
|
||||
return tokenRequest(url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {rawRefresh},
|
||||
"client_id": {clientID},
|
||||
}, "")
|
||||
}
|
||||
|
||||
// refreshRowRevoked reports whether the refresh row matching rawRefresh has
|
||||
// a non-nil RevokedAt. The caller must already hold backend.mu.
|
||||
func refreshRowRevoked(backend *memoryBackend, rawRefresh string) bool {
|
||||
hash := sha256Hex(rawRefresh)
|
||||
for _, r := range backend.refresh {
|
||||
if r.TokenHash == hash {
|
||||
return r.RevokedAt != nil
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// accessTokenRevoked reports whether the IssuedToken matching rawAccess is
|
||||
// marked revoked in backend.revoked. The caller must already hold
|
||||
// backend.mu.
|
||||
func accessTokenRevoked(backend *memoryBackend, rawAccess string) bool {
|
||||
for _, tok := range backend.tokens {
|
||||
if tok.Secret == rawAccess {
|
||||
return backend.revoked[tok.ID]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestRefreshRotationIssuesNewPairAndKeepsPredecessorAsEvidence proves D-04's
|
||||
// normal-path rotation: a fresh (not-yet-rotated) refresh token succeeds
|
||||
// exactly once, mints a new access/refresh pair with the same scopes, and
|
||||
// leaves the predecessor row retrievable (not deleted) with RotatedToID set
|
||||
// but RevokedAt nil -- a rotated-but-unreplayed row is not itself "revoked"
|
||||
// (D-17 evidence retention).
|
||||
func TestRefreshRotationIssuesNewPairAndKeepsPredecessorAsEvidence(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-rotate", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-rotate", challenge, nil)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-rotate"), ""))
|
||||
firstAccess, firstRefresh := decodeTokenPair(t, first)
|
||||
|
||||
rotate := httptest.NewRecorder()
|
||||
srv.Token(rotate, refreshRequest(firstRefresh, "cli-rotate"))
|
||||
if rotate.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", rotate.Code, http.StatusOK, rotate.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rotate.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["scope"] != "read write" {
|
||||
t.Fatalf("scope = %v, want %q", got["scope"], "read write")
|
||||
}
|
||||
secondAccess, secondRefresh := decodeTokenPair(t, rotate)
|
||||
if secondAccess == firstAccess || secondRefresh == firstRefresh {
|
||||
t.Fatal("rotation must mint a brand new access/refresh pair")
|
||||
}
|
||||
|
||||
backend.mu.Lock()
|
||||
defer backend.mu.Unlock()
|
||||
hash := sha256Hex(firstRefresh)
|
||||
for _, r := range backend.refresh {
|
||||
if r.TokenHash == hash {
|
||||
if r.RevokedAt != nil {
|
||||
t.Fatal("a rotated-but-unreplayed predecessor must not itself be revoked")
|
||||
}
|
||||
if r.RotatedToID == nil {
|
||||
t.Fatal("predecessor must have RotatedToID set to its successor")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("predecessor refresh row not found (must not be deleted)")
|
||||
}
|
||||
|
||||
// TestRefreshWrongClientIsInvalidGrant proves the client-binding check: a
|
||||
// refresh token issued to one client cannot be redeemed by another.
|
||||
func TestRefreshWrongClientIsInvalidGrant(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-owner-r", "none", nil, nil)
|
||||
insertTokenTestClient(backend, "cli-other-r", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-owner-r", challenge, nil)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-owner-r"), ""))
|
||||
_, firstRefresh := decodeTokenPair(t, first)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, refreshRequest(firstRefresh, "cli-other-r"))
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
// TestRefreshExpiredIsInvalidGrant proves an expired refresh row is
|
||||
// rejected even though it was never rotated or revoked.
|
||||
func TestRefreshExpiredIsInvalidGrant(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-refresh-expired", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-refresh-expired", challenge, nil)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-refresh-expired"), ""))
|
||||
_, firstRefresh := decodeTokenPair(t, first)
|
||||
|
||||
backend.mu.Lock()
|
||||
hash := sha256Hex(firstRefresh)
|
||||
for _, r := range backend.refresh {
|
||||
if r.TokenHash == hash {
|
||||
r.ExpiresAt = time.Now().Add(-1 * time.Minute)
|
||||
}
|
||||
}
|
||||
backend.mu.Unlock()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, refreshRequest(firstRefresh, "cli-refresh-expired"))
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
// TestRefreshUnknownTokenIsInvalidGrant proves a syntactically valid but
|
||||
// never-issued refresh secret is rejected exactly like PHP's missing-record
|
||||
// case, not distinguished from any other invalid_grant.
|
||||
func TestRefreshUnknownTokenIsInvalidGrant(t *testing.T) {
|
||||
srv, _, _, _, _ := newTokenExchangeFixture(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, refreshRequest("does-not-exist-at-all", "cli-tok"))
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
// TestRefreshMissingTokenIsInvalidGrant proves an empty refresh_token value
|
||||
// is rejected before any store lookup.
|
||||
func TestRefreshMissingTokenIsInvalidGrant(t *testing.T) {
|
||||
srv, _, _, _, _ := newTokenExchangeFixture(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, tokenRequest(url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"client_id": {"cli-tok"},
|
||||
}, ""))
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
// TestRefreshConcurrentReplayHasExactlyOneWinner is the concurrent half of
|
||||
// T-08-REFRESH-REPLAY: two synchronized goroutines racing to rotate the same
|
||||
// refresh token must produce exactly one 200 and one invalid_grant, never
|
||||
// two successors sharing one predecessor.
|
||||
func TestRefreshConcurrentReplayHasExactlyOneWinner(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-refresh-race", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-refresh-race", challenge, nil)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-refresh-race"), ""))
|
||||
_, firstRefresh := decodeTokenPair(t, first)
|
||||
|
||||
results := make([]int, 2)
|
||||
start := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
for i := range 2 {
|
||||
go func(i int) {
|
||||
<-start
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, refreshRequest(firstRefresh, "cli-refresh-race"))
|
||||
results[i] = rec.Code
|
||||
done <- struct{}{}
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
<-done
|
||||
<-done
|
||||
|
||||
successCount, grantErrCount := 0, 0
|
||||
for _, code := range results {
|
||||
switch code {
|
||||
case http.StatusOK:
|
||||
successCount++
|
||||
case http.StatusBadRequest:
|
||||
grantErrCount++
|
||||
default:
|
||||
t.Fatalf("unexpected status %d", code)
|
||||
}
|
||||
}
|
||||
if successCount != 1 || grantErrCount != 1 {
|
||||
t.Fatalf("successCount=%d grantErrCount=%d, want 1 and 1 (results=%v)", successCount, grantErrCount, results)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenSweepDeletesExpiredRowsButKeepsUnexpiredEvidence proves D-17: an
|
||||
// expired pending/code row and an expired refresh row are gone after the
|
||||
// next /token call's sweep, while an unexpired-but-revoked refresh row (real
|
||||
// replay evidence) survives untouched.
|
||||
func TestTokenSweepDeletesExpiredRowsButKeepsUnexpiredEvidence(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-sweep", "none", nil, nil)
|
||||
|
||||
_, expiredCode := insertTokenTestCode(t, backend, "cli-sweep", "unused-challenge", func(rec *AuthCodeRecord) {
|
||||
rec.ExpiresAt = time.Now().Add(-1 * time.Hour)
|
||||
})
|
||||
expiredCodeID := expiredCode.ID
|
||||
|
||||
backend.mu.Lock()
|
||||
backend.nextID++
|
||||
expiredRefreshID := backend.nextID
|
||||
backend.refresh = append(backend.refresh, &RefreshTokenRecord{
|
||||
ID: expiredRefreshID,
|
||||
TokenHash: sha256Hex("expired-refresh-secret"),
|
||||
ClientID: "cli-sweep",
|
||||
UserID: 1,
|
||||
Scopes: []string{"read"},
|
||||
ExpiresAt: time.Now().Add(-1 * time.Hour),
|
||||
})
|
||||
now := time.Now()
|
||||
backend.nextID++
|
||||
evidenceRefreshID := backend.nextID
|
||||
backend.refresh = append(backend.refresh, &RefreshTokenRecord{
|
||||
ID: evidenceRefreshID,
|
||||
TokenHash: sha256Hex("revoked-but-unexpired-refresh-secret"),
|
||||
ClientID: "cli-sweep",
|
||||
UserID: 1,
|
||||
Scopes: []string{"read"},
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
RevokedAt: &now,
|
||||
})
|
||||
backend.mu.Unlock()
|
||||
|
||||
// Any /token call runs the sweep; use a deliberately-broken grant so no
|
||||
// mutation beyond the sweep happens.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, tokenRequest(url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {"does-not-exist"},
|
||||
"client_id": {"cli-sweep"},
|
||||
}, ""))
|
||||
|
||||
backend.mu.Lock()
|
||||
defer backend.mu.Unlock()
|
||||
for _, c := range backend.codes {
|
||||
if c.ID == expiredCodeID {
|
||||
t.Fatal("expired code row must be swept")
|
||||
}
|
||||
}
|
||||
for _, r := range backend.refresh {
|
||||
if r.ID == expiredRefreshID {
|
||||
t.Fatal("expired refresh row must be swept")
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, r := range backend.refresh {
|
||||
if r.ID == evidenceRefreshID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("unexpired revoked refresh row must survive the sweep as replay evidence")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerRevokeKillsAccessAndLineage proves Server.Revoke (the seam the
|
||||
// app's connected-app controller calls, 08-06-PLAN.md D-08): revoking a live
|
||||
// OAuth access token also revokes its linked refresh row.
|
||||
func TestServerRevokeKillsAccessAndLineage(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-revoke", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-revoke", challenge, nil)
|
||||
|
||||
exchange := httptest.NewRecorder()
|
||||
srv.Token(exchange, tokenRequest(validExchangeForm(rawCode, verifier, "cli-revoke"), ""))
|
||||
access, refresh := decodeTokenPair(t, exchange)
|
||||
|
||||
backend.mu.Lock()
|
||||
var tokenID uint
|
||||
for _, tok := range backend.tokens {
|
||||
if tok.Secret == access {
|
||||
tokenID = tok.ID
|
||||
}
|
||||
}
|
||||
backend.mu.Unlock()
|
||||
if tokenID == 0 {
|
||||
t.Fatal("minted access token not found in backend")
|
||||
}
|
||||
|
||||
if err := srv.Revoke(t.Context(), tokenID); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
|
||||
backend.mu.Lock()
|
||||
if !backend.revoked[tokenID] {
|
||||
t.Fatal("access token must be revoked")
|
||||
}
|
||||
if !refreshRowRevoked(backend, refresh) {
|
||||
t.Fatal("linked refresh row must be revoked")
|
||||
}
|
||||
backend.mu.Unlock()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, refreshRequest(refresh, "cli-revoke"))
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
func TestTokenBackendUnavailableIsOpaque500(t *testing.T) {
|
||||
opts := DefaultOptions()
|
||||
opts.Issuer = "https://plytarium.com"
|
||||
|
||||
Reference in New Issue
Block a user