Skip to content

fix(oauth2): make refresh-family revocation reliable across stores - #91

Merged
euskadi31 merged 8 commits into
masterfrom
feature/82-oauth2-refresh-family-revocation
Oct 10, 2026
Merged

euskadi31 merged 8 commits into
masterfrom
feature/82-oauth2-refresh-family-revocation

Conversation

@euskadi31

@euskadi31 euskadi31 commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Closes #82.

Problem

The storage contract promised that replaying a rotated refresh token revokes
its whole token family, but no store gave that revocation a family-wide
boundary, and nothing gated issuance against it:

  • SQL — RevokeRefreshFamily ran UPDATE oauth2_refresh_tokens SET consumed = 1 WHERE family_id = ? and DELETE FROM oauth2_access_tokens WHERE family_id = ? as two autocommit statements: a token committed by a
    concurrent rotation outside the UPDATE's snapshot escaped it (PostgreSQL
    READ COMMITTED), and a failure between the two statements left half a
    revocation that nothing recorded.
  • Redis — revocation enumerated famrt:F / famat:F with SMEMBERS,
    then mutated the members one command at a time: a rotation landing after
    the snapshot stayed active.
  • Swallowed reuse failures — on the reuse path the SQL and Redis stores
    discarded the revocation error (_ = s.RevokeRefreshFamily(...)) and
    returned the bare ErrRefreshTokenReused, whose description still said
    "family revoked".
  • Partial issuance — the grants persisted the access token before minting
    the refresh token and rotating: any later failure left a live access token
    the client never received.
  • Nothing recorded the revocation of a family without tokens yet, and nothing
    stopped a non-rotating Profile20 refresh from issuing into a family whose
    revocation had just completed.

Reproductions run on the base before any change (scratch tests, not
committed), all confirmed:

Scenario Observed
(a) Redis: a rotation RT1→RT2 lands between the revocation's SMEMBERS famrt:F and its writes RevokeRefreshFamily returns nil, RT2 reads Consumed=false
(b) Non-rotating Profile20 refresh; the family is revoked right after the grant's lookup 200, and the new access token is live after the revocation completed
(c) Failing refresh-token generator, refresh grant and authorization_code server_error, yet the minted access token is live in the store
(d) SQLite trigger failing the revocation's DELETE, then a replayed rotation the exact bare ErrRefreshTokenReused sentinel, no cause; the family's access token stays live
(e) RevokeRefreshFamily(""), memory and SQL returns nil and deletes a client_credentials access token

Also found on the way:

  • Empty-family mass revocation — (e): the empty family ID matched every
    token issued outside a family (client_credentials, implicit).
  • The SQL schema only ran on SQLite — PostgreSQL rejects
    consumed BOOLEAN NOT NULL DEFAULT 0 and the store's consumed = 0/1
    comparisons (no implicit integer/boolean cast); MySQL rejects
    CREATE INDEX IF NOT EXISTS.
  • go-redis resends a command whose reply was lost — the resent rotation
    script saw its own consumption and answered "refresh token reused",
    revoking the family the request had just rotated into (reproduced with a
    hook resending the script against the old store).
  • A refresh token vanishing between the grant's lookup and its rotation (a
    Redis TTL) answered server_error instead of invalid_grant; the Redis
    reuse path revoked the family the caller declared, not the stored one; and
    /revoke answered 200 OK when its lookup failed for a backend reason (the
    revocation was skipped without a trace) or when the revocation failed.

Fix

One authoritative record per family. Every store keeps the state of each
token family — active or revoked — and serializes every write into the
family on it: a row of the new oauth2_token_families table locked with
SELECT … FOR UPDATE in SQL (SQLite's single writer otherwise), one
fam:{id} key written only by Lua scripts in Redis, the mutex in memory.
Lookups consult the record, so a revocation is a single idempotent write:
nothing to enumerate or purge, and it works before the family's first token
exists.

Atomic pairs. SaveAccessToken / SaveRefreshToken give way to
SaveTokenPair, which persists a newly issued pair — both tokens or neither
— and refuses a revoked family; RotateRefreshToken(ctx, oldHash, next *TokenPair) consumes the presented token and persists the whole
replacement pair atomically. Each grant exchange makes exactly one
persistence call.

The guarantees, stated on the oauth2.Storage godoc and checked by
storetest:

  • Visibility and gating (I1, I2) — once a revocation completes
    (RevokeRefreshFamily returned nil, or a reuse was reported without
    ErrTokenFamilyRevocationFailed), no token of the family is usable through
    the store — access lookups fail with invalid_grant, refresh tokens read
    consumed — and every issuance or rotation into the family, including one
    racing the revocation, is ordered either before it, and covered, or after
    it, and refused with ErrTokenFamilyRevoked without persisting anything.
  • Atomic writes (I3) — a failed SaveTokenPair or RotateRefreshToken
    persists no token and consumes nothing; reuse only records the revocation.
  • Reuse (I4) — a consumed token of a non-revoked family yields
    ErrRefreshTokenReused; when the store's revocation fails, the error also
    wraps the new plain sentinel ErrTokenFamilyRevocationFailed and its
    cause, and the family must be treated as active.
  • Idempotence and retention (I5) — a revoked family never becomes active
    again and revoking it again is a no-op; it is kept at least as long as its
    tokens, and for FamilyRevocationRetention (24h) after its revocation.
  • Failure (I6) — a failed revocation applies nothing, but its outcome may
    be unknown: treat the family as active and retry.
  • Isolation (I7) — nothing done to a family touches another one or a token
    outside any family; an empty or over-long (MaxFamilyIDLength, 64 bytes)
    family ID is refused with invalid_request.
  • Fail closed (I8) — a token whose family record is missing is unusable.
    This protects lookups only, hence the retention requirements below.

One decision order for a rotation, in every store: a malformed next is
refused with invalid_request before any I/O; then an unknown old token →
invalid_grant; next in another family → invalid_request; a revoked or
unknown family → ErrTokenFamilyRevoked; a consumed old token → reuse.

Per backend:

  • SQL — every write transaction starts with a write (insert-if-absent of
    the family row: ON CONFLICT DO NOTHING / ON DUPLICATE KEY UPDATE), then
    locks the family row and the old token (FOR UPDATE on PostgreSQL and
    MySQL), decides, and writes. A rotation inserts an unknown family as
    revoked, so only an issuance creates an active family. PostgreSQL's
    token-family transactions are pinned to READ COMMITTED. Lookups are one
    statement with a LEFT JOIN on the family row; no row reads as revoked.
  • Redis — three scripts. issuePair refuses any family state but unknown
    or active. rotatePair checks the old token, the family and the
    consumption before its first write, keeps the consumed token's TTL
    (SET … KEEPTTL), and recognizes its own resend (the new refresh key
    already exists → ok, not reused). revokeFamily writes the single
    family key with the longer of its TTL and the retention. On reuse the store
    runs revokeFamily right after the rotation script: anything committed in
    between is ordered before the revocation, which covers it. Lookups require
    the family key to hold exactly active.
  • Memory — a revoked-family set under the existing mutex.

Grants. issueTokenPair and the refresh grant mint everything first,
then persist once. A reuse — found by the grant (rt.Consumed) or reported by
the store — revokes the family on a context detached from the request
(context.WithoutCancel plus a 10s timeout), so a client hanging up cannot
abort it. When the store reports a failed revocation the grant retries it
once; a remaining failure reaches grant.Config.OnError as exactly one
server_error (revoke refresh family failed after reuse detection, its
cause naming the family). The client gets invalid_grant either way. A
refusal the store reports as invalid_grant (revoked family, vanished token)
passes through; any other storage error is a server_error with its cause
off the wire.

/revoke and RFC 7009 §2.2.1. RFC 7009 §2.1 lets a client take 200 OK
as "the token cannot be used again", so a revocation that may not have taken
effect now answers 503 temporarily_unavailable — after which the client
"must assume the token still exists and may retry":

  • the token's lookup fails for another reason than an unknown token;
  • a refresh token's family cannot be revoked;
  • for an access token of a family, /revoke revokes the family
    first: if that fails, it answers 503 without deleting the access
    token
    , so the retry redoes both. Once the family is revoked, the access
    token is deleted best-effort: a failed delete is reported and still
    answers 200 OK, the token being unusable through its revoked family;
  • an access token outside any family cannot be deleted.

Each storage failure reaches ServerConfig.OnError exactly once as a
server_error carrying its cause; the 503 envelope is written without a
second notification, so sinks filtering on server_error see one event per
failure. Unknown tokens and other clients' tokens still answer 200 OK.

Schema portability. consumed is a SMALLINT on every engine and the
family indexes are gone; Schema returns the same DDL for every dialect.

Docs: the oauth2.Storage godoc (I1–I8), the SQL and Redis package docs
(deployment requirements, key layout, cleanup rules), CHANGELOG.md,
MIGRATION.md, LIMITATIONS.md, docs/security-considerations.md (new
"Token-family revocation" and "Offline JWT access tokens" sections),
docs/architecture.md, docs/observability.md.

Breaking changes

Nothing is tagged yet, but code tracking master is affected — see
MIGRATION.md.

  • oauth2.Storage contract — SaveAccessToken and SaveRefreshToken
    are removed for SaveTokenPair; RotateRefreshToken takes a
    *TokenPair; RevokeRefreshFamily refuses "" and IDs over 64 bytes.
    Access and refresh tokens must share one backend, and
    ServerConfig.Storage and every grant.Config.Storage must be the same
    store. Custom stores must pass storetest.
  • SQL schema — Migrate adds oauth2_token_families and leaves the
    existing tables untouched. Never delete a family row before its
    expires_at.
  • Redis keys and requirements — fam:{id} replaces the famrt: /
    famat: sets (old keys are ignored; delete them with SCAN if you like).
    Redis ≥ 6.0 (SET … KEEPTTL) and maxmemory-policy noeviction are
    now required: family keys carry TTLs, so the volatile-* policies evict them
    too, and a revoked family whose key is lost can be recorded active again by
    an issuance into it. Standalone server or Sentinel primary only (not
    Cluster), reads from the primary.
  • Tokens issued before the upgrade — every refresh token and every
    family-bound access token becomes unusable (no family record, fail closed):
    users re-authenticate, and pre-upgrade refresh tokens answer
    invalid_grant ("refresh token reused"). client_credentials and
    implicit tokens keep working.
  • /revoke answers 503 when a revocation may not have taken effect. This
    reverses the "responses unchanged" of the error-hook entry (oauth2: the cause of a server_error is unobservable (no logger or error hook on ServerConfig) #63) for failed
    revocations; clients that ignored the status of /revoke should retry on
    503.
  • The error_description of ErrRefreshTokenReused is now refresh token reused: "family revoked" could be false, and the em dash was outside the
    RFC 6749 §5.2 charset.

Tests

storetest — run by memory, the suite itself, SQLite and Redis. The
existing cases are migrated and twelve family cases are added in the new
(linted) storetest/family.go: revocation before issuance, rotate-then-revoke,
revoke-then-rotate, idempotence, isolation, empty and over-long IDs, failed
rotations that persist nothing (unknown token, family mismatch, no refresh
token, nil target, revoked family — each target declaring an existing active
family, so a wrongly persisted token would be found), invalid pairs,
access-only issuance gating, and two race cases (a revocation racing a
rotation chain, 20 concurrent issuances racing a revocation). Race results are
asserted after Wait — no t.Fatal in goroutines — and every assertion goes
through helpers. The reuse case replays twice and pins the revoked-before-
consumed order; the concurrent-rotation case asserts that the winner's tokens
end up revoked by the first loser.

SQL (store_family_test.go, SQLite triggers) — a revocation failure at
each statement is reported (ErrTokenFamilyRevocationFailed, family ID,
cause) and a retry succeeds; a reuse whose revocation fails reports both
sentinels and leaves the family active; rotation and issuance stay atomic
under injected failures (tables inspected directly); retention arithmetic; a
missing family row fails closed and a rotation never re-creates it; a
RAISE(IGNORE) insert pins the no-row lock branch; a refresh token stored
without a family reads consumed; exact dialect statements, the PostgreSQL
rebind and the isolation pin.

Redis (store_family_test.go, go-redis hooks acting before EVALSHA /
EVAL) — failure injection on the revoke script, directly and on the reuse
path; exact interleavings: a rotation just before the revocation, a
revocation just before the rotation, an attacker rotating between the reuse
detection and its revocation, issuance before and after a revocation; a
resent rotation is not a reuse; family TTLs (cover every token, never shrink,
retention floor, KEEPTTL on the consumed token); missing, unexpected and
WRONGTYPE family state fail closed; no famrt: / famat: key is written.

Grant (grant/family_test.go, a recording decorator over memory) — one
persistence call per exchange, kind and arguments asserted; no access token
left behind by a failing generator or rotation (refresh and
authorization_code); a store-reported reuse with a failed revocation gives
one hook event whose cause matches ErrTokenFamilyRevocationFailed and not
ErrRefreshTokenReused, and none when the retry succeeds; an already
canceled request still revokes, on a live context with a deadline; a
revocation or a reuse landing between lookup and rotation; a non-rotating
Profile20 refresh refused in a revoked family; a vanished token answers
invalid_grant; storage failures map to invalid_grant / server_error.

HTTP — the store-reported reuse failure over /token (exact wire body,
cause on the grant hook, invalid_grant on the server hook); /revoke of
the newest refresh token revokes the whole family (introspection inactive,
refresh refused); tokens of a revoked family never reach the
IntrospectionPolicy; the /revoke 503 matrix (lookup failures, a family
failure leaving the access token for the retry, a failed delete covered by
the family, a family-less delete failure, another client's token, an already
revoked family). The #78 and #81 tests are kept unweakened: their seeds go
through SaveTokenPair, and consumed tokens now come from a real rotation.

Red phase — beyond the reproductions above, every new test was run
against the previous behavior: the nine family conformance cases the old
semantics break (issuance into a revoked family accepted, the access token of
a failed rotation persisted, …), every new SQL and Redis test against a port
of the old stores, the eleven grant tests written first against an emulation
of the old flow (two persistence calls, orphan tokens, reuse instead of
ErrTokenFamilyRevoked, server_error for a vanished token, no retry, no
hook), and the /revoke tests (200 instead of 503, lookup failures never
reported).

Stability — every race-sensitive test passes with -race -count=30 on
memory, SQLite and Redis.

Reviews — two independent adversarial reviews. The store review ran the
conformance suite and stress tests against real PostgreSQL 17, MySQL 9.6,
Redis 8.8 and a multi-connection SQLite, with 36 mutants; the grant/endpoint
review killed 17 mutants. Their findings are applied in the last three
commits: family-first revocation at /revoke, two newly pinned branches,
and the retention requirement in the docs.

Checks

  • make build — OK.

  • make test — every package passes with -race:

    Package Before After
    oauth2 92.3% 93.0%
    oauth2/grant 91.7% 97.5%
    oauth2/storage/memory 91.9% 98.7%
    oauth2/storetest 81.2% 97.5%
    oauth2/store/sql 91.2% 95.0%
    oauth2/store/redis 89.7% 94.4%

    What remains uncovered cannot be reached on SQLite or miniredis (commit
    failures, lock-invariant guards, JSON-encoding errors, defensive
    "unexpected script result" branches) or is pre-existing.

  • make lint — 0 issues.

Follow-ups (out of scope)

  • PostgreSQL and MySQL are not exercised in CI (stated in LIMITATIONS.md);
    a job with service containers would turn the locking argument into a test.
  • /revoke of another client's token answers 200 without revoking anything
    (unchanged).
  • /introspect still reads a storage error as an unknown token
    ({"active":false}) without reporting it.
  • The memory store never purges expired tokens or revoked families (dev
    store).
  • A stale "OAuth 2.0 BCP §8.10" citation remains in
    oauth2/token/generator.go.

Notes for the maintainer

  • CLAUDE.md is not edited here. Proposed addition to the storage bullet of
    the "OAuth2 server" section:

    Token writes are atomic pairs (SaveTokenPair,
    RotateRefreshToken(old, *TokenPair)) against one authoritative record per
    token family (oauth2_token_families / fam:{id}): RevokeRefreshFamily
    records the revocation, lookups honour it, nothing is purged. The
    invariants live on the oauth2.Storage godoc.

The token-family work of #82 needs a vocabulary the storage contract can
rely on before any store changes. ErrTokenFamilyRevoked is the
invalid_grant a store answers for an operation on a revoked family.
ErrTokenFamilyRevocationFailed reports a family revocation that could
not be recorded; it is a plain sentinel rather than an *Error, so it
never reaches the wire on its own and keeps matching through errors.Is
once wrapped (an *Error has no Is method: a WithDescription copy no
longer matches its sentinel).

TokenPair.Validate rejects a pair no store may persist: a refresh token
without a family, in another family than its access token or already
consumed, and a family identifier longer than MaxFamilyIDLength — the
64 bytes the SQL schema stores, so no backend can silently truncate two
families into one. FamilyRevocationRetention bounds how long a revoked
family is remembered.

ErrRefreshTokenReused now reads "refresh token reused": the former
description claimed the family was revoked even when that revocation
had failed, and its em dash lies outside the RFC 6749 §5.2
error_description charset. Refs #82.
The sqlstore module documents PostgreSQL, MySQL and SQLite, but its DDL
only ran on SQLite, the one engine CI exercises. PostgreSQL rejected the
refresh-token table outright — a BOOLEAN column cannot default to 0 —
and would then have refused every consumed = 0 / consumed = 1
comparison the store issues, since it has no implicit integer/boolean
cast. MySQL rejected the CREATE INDEX IF NOT EXISTS statements of the
two token-family indexes.

consumed is now a SMALLINT holding 0/1 on every engine, and the family
indexes are dropped: the authoritative family record #82 introduces
makes lookups by family unnecessary, and no portable idempotent CREATE
INDEX exists. Schema therefore returns the same DDL whatever the
dialect; the argument is kept and ignored. Refs #82.
The storage contract promised family revocation on refresh-token reuse,
but no store gave it a family-wide boundary. The SQL store ran its
UPDATE and DELETE outside a shared transaction, the Redis store
enumerated the family before mutating it member by member, and nothing
gated issuance: a rotation, or the non-rotating Profile20 refresh, could
leave a usable token in a family whose revocation had just returned,
and a family revoked before its first token left no trace at all. On
the reuse path the SQL and Redis stores discarded the revocation error
and returned the bare ErrRefreshTokenReused. The grants persisted the
access token before minting and rotating the refresh token, so a later
failure left a live access token the client never received.

Every store now keeps one authoritative record per token family —
active or revoked — and serializes every write into the family on it:
a row locked with SELECT ... FOR UPDATE in SQL (SQLite's single writer
otherwise), one key written by Lua scripts in Redis, the mutex in
memory. Lookups consult the record, so a revocation is one idempotent
write that needs no purge and works before the first issuance, and a
missing record fails closed. Issuance and rotation become single atomic
operations: SaveTokenPair replaces SaveAccessToken and
SaveRefreshToken, RotateRefreshToken takes the whole replacement pair,
and each grant exchange makes exactly one persistence call.

A reuse whose revocation fails now returns ErrRefreshTokenReused joined
with ErrTokenFamilyRevocationFailed; the refresh grant retries the
revocation on a context detached from the request and reports a second
failure through grant.Config.OnError, while the client still gets
invalid_grant. A token that vanished before its rotation answers
invalid_grant instead of server_error, the empty family ID is refused
instead of revoking every family-less token, and a Redis rotation
resent by go-redis after a lost reply is no longer taken for a reuse.

This breaks oauth2.Storage, the SQL schema (new oauth2_token_families
table) and the Redis key layout (fam:{id} replaces the famrt:/famat:
sets). Tokens issued before the upgrade belong to a family without a
record and are therefore unusable: users re-authenticate. The
guarantees are stated on oauth2.Storage and enforced by storetest,
which the memory, SQL and Redis stores pass, concurrent revocations
included. Refs #82.
/revoke answered 200 OK whatever happened. A failed RevokeAccessToken or
RevokeRefreshFamily only reached the error hook, and a token lookup that
failed for a backend reason skipped the revocation without any trace:
in both cases the client was told its token was gone while it may still
have been usable. RFC 7009 §2.2 answers 200 for a token that was
revoked or is invalid; §2.2.1 provides 503 for a server that cannot
complete the revocation, after which the client must assume the token
still exists and may retry.

/revoke now answers 503 temporarily_unavailable when a lookup fails for
another reason than an unknown token, or when the revocation fails and
leaves the token usable: the access token could not be deleted and its
family, if any, could not be revoked either, or the refresh token's
family could not be revoked. A deleted access token whose family
revocation failed, or a failed delete covered by the family revocation,
still answers 200. Unknown tokens and other clients' tokens answer 200
as before, so the response still tells nothing about a token the
caller does not hold. Retries are safe: revocation is idempotent.

Each failure reaches the error hook exactly once, as a server_error
carrying its cause — a failed lookup as "revoke: token lookup failed";
the 503 body is written without a second notification, so sinks
filtering on server_error see the same events as before. This reverses
the "responses unchanged" of the error-hook entry for failed
revocations. Refs #82.
… JWT limits

The storage contract now guarantees what a family revocation means, and
the deployment documentation has to say it, along with what it does not
cover.

docs/security-considerations.md gains a "Token-family revocation"
section: when a revocation is complete, how concurrent issuance and
rotation are ordered around it, revocation before the first token and
its retention, atomic writes, idempotence and isolation, the failure
and retry rule behind the /revoke 503 and the reuse-path report, the
fail-closed treatment of lost family state, and the backend assumptions
the guarantees rest on. A separate "Offline JWT access tokens" section
states that a store revocation never reaches a resource server
verifying JWTs offline: the RFC 9068 payload carries no family or jti
claim, so such a token stays valid until it expires (RFC 7009 §2.1,
§3), and lists the mitigations. The rotation bullet links to the new
section, and the atomicity bullet replaces the inaccurate
"100-goroutine races" with what the conformance suite checks.

MIGRATION.md maps the removed Storage methods to SaveTokenPair and the
new RotateRefreshToken, lists what custom stores, the SQL and Redis
deployments and pre-upgrade tokens need, and describes the /revoke
503. LIMITATIONS.md records offline JWT revocation, Redis Cluster, the
missing SQL expiry job and the PostgreSQL/MySQL CI gap; architecture.md,
observability.md and the JWT generator godoc are updated to match.
Refs #82.
…evoke

Revoking an access token at /revoke deleted the token first, then
revoked its family, and answered 200 OK whenever the delete had
succeeded. A family revocation that failed was reported to the error
hook but could never be redone: a retry looked the deleted access token
up, missed it, and answered 200 OK again, while the family's refresh
tokens stayed usable — although the documentation promised a 503 for an
unfinished revocation.

The family is now revoked first. When that fails, the access token is
left in place and /revoke answers 503 temporarily_unavailable (RFC 7009
§2.2.1), so the retry the client is told to make redoes both. Once the
family is revoked, no token of it is usable, and deleting the access
token is cleanup: a failed delete is reported but still answers 200 OK.
A token outside any family is deleted, and a failed delete still
answers 503. Each storage failure keeps reaching the error hook exactly
once. Refs #82.
…rder

Two branches of the token-family contract were implemented but not
pinned by a test, so a regression would have gone unnoticed.

The SQL store reads a family it could not find, right after its
insert-if-absent, as revoked: the record may vanish under a concurrent
cleanup on PostgreSQL. A SQLite trigger that silently drops the insert
now exercises it: neither an issuance nor a rotation goes through, and
nothing is persisted.

The storage contract decides a revoked family before a consumed token.
The conformance suite now replays a token once more after its reuse
revoked the family, and every store must refuse it as a revoked family
rather than as another reuse.

Also drop the nolint directives of the new test doubles: test files are
not linted. Refs #82.
The token-family documentation claimed more than the stores guarantee.
A lookup does treat a token whose family state is lost as revoked, but
the next issuance into that family records it active again: losing a
revoked family's state before its retention ends — an evicted Redis key,
a family row deleted early — can revive its tokens. The Redis package
documentation called eviction harmless and noeviction a recommendation,
and the SQL one called an early family delete safe.

Keeping that state for its whole retention is now a stated requirement:
the Storage godoc (I8), the SQL cleanup rules (never delete a family
row before its expires_at), the Redis requirements (maxmemory-policy
noeviction, since family keys carry TTLs and volatile-* policies evict
them too), the security considerations, the migration guide and the
changelog, which also names the Redis 6.0 requirement KEEPTTL brings.
I6 now mentions the invalid_request for a malformed family ID, the
READ COMMITTED pin is scoped to the token-family transactions it
applies to, and only the token-family Redis scripts are claimed safe to
resend. Refs #82.
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 38014578498

Coverage increased (+2.4%) to 94.398%

Details

  • Coverage increased (+2.4%) from the base build.
  • Patch coverage: 27 uncovered changes across 4 files (1002 of 1029 lines covered, 97.38%).
  • 2 coverage regressions across 2 files.

Uncovered Changes

File Changed Covered %
oauth2/store/redis/store.go 109 101 92.66%
oauth2/storetest/conformance.go 130 122 93.85%
oauth2/store/sql/store.go 213 206 96.71%
oauth2/storetest/family.go 344 340 98.84%
Total (16 files) 1029 1002 97.38%

Coverage Regressions

2 previously-covered lines in 2 files lost coverage.

File Lines Losing Coverage Coverage
oauth2/store/redis/store.go 1 91.79%
oauth2/store/sql/store.go 1 93.88%

Coverage Stats

Coverage Status
Relevant Lines: 4909
Covered Lines: 4634
Line Coverage: 94.4%
Coverage Strength: 21.32 hits per line

💛 - Coveralls

@euskadi31
euskadi31 merged commit 48bfaaf into master Oct 10, 2026
2 checks passed
@euskadi31
euskadi31 deleted the feature/82-oauth2-refresh-family-revocation branch October 10, 2026 01:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

oauth2/store: make refresh-family revocation reliable across races and storage failures

2 participants