Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree
consumed); `oauth2.MaxFamilyIDLength` (64 bytes) and
`oauth2.FamilyRevocationRetention` (24h) bound family identifiers and how
long a revoked family is remembered.
- **OAuth2 code replay error**: `oauth2.CodeReplayError`, the error an
`AuthorizationCodeStore` returns for a replayed code it still retains. It
matches `oauth2.ErrCodeAlreadyUsed` (`invalid_grant`) for `errors.Is`,
`oauth2.IsCode` and the wire, and carries the retained record for
`errors.As`.

### Changed

Expand Down Expand Up @@ -140,6 +145,18 @@ legacy packages (`authentication/`, `authorization/`, the in-tree
presenting such a refresh token answers `invalid_grant`;
`client_credentials` and implicit tokens keep working. See
[MIGRATION.md](MIGRATION.md).
- **OAuth2 authorization-code storage (breaking).**
`ConsumeAuthorizationCode` no longer deletes the code it consumes: every
store keeps the record, flagged consumed and without the raw code, at
least until the code expires, and answers a replay with an
`*oauth2.CodeReplayError` carrying it; an unknown code still gets
`ErrCodeAlreadyUsed`. `store/sql` adds a `consumed` column to
`oauth2_auth_codes` — existing tables need an `ALTER TABLE` — and keeps
consumed rows until the operator's cleanup; `store/redis` flags the
code's key and keeps its TTL, and a consumption resent by go-redis after
a lost reply no longer fails the exchange; the memory store no longer
keeps the raw code. Every instance must be upgraded at once. See
[MIGRATION.md](MIGRATION.md).

### Fixed

Expand Down Expand Up @@ -276,3 +293,22 @@ legacy packages (`authentication/`, `authorization/`, the in-tree
codes; their `error_description` no longer contains a double quote,
which RFC 6749 §4.1.2.1 and §5.2 forbid, and no longer echoes an
unsupported `code_challenge_method`.
- A replayed OAuth2 authorization code now revokes every token issued from
it — access and refresh tokens, and every later rotation — in every store
(RFC 6749 §4.1.2, §10.5; RFC 9700 §4.2.4). The `authorization_code` grant
derives the token family from the code before consuming it, so a replay
racing the first exchange cannot leave its tokens active: one landing
before that exchange persists its tokens makes it fail with
`invalid_grant` — as long as the store keeps the revoked family record
(see the deployment requirements in
[docs/security-considerations.md](docs/security-considerations.md)) — and
one landing after revokes them. Only a replay that would itself have been
accepted revokes: the code has not expired, and the client it was issued
to presents it with the same `redirect_uri` and PKCE verifier and may
still use `authorization_code` (OAuth 2.1 §4.1.3, §7.5.3). A public
`client_id` is no secret, so unknown codes, other clients and missing or
wrong verifiers revoke nothing. The replay is still refused with
`invalid_grant`, and a failed revocation is reported through
`grant.Config.OnError`. JWT access tokens validated offline stay valid
until they expire. Previously the tokens issued from a replayed code
stayed valid.
9 changes: 7 additions & 2 deletions LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ than future refactor phases.
scripts touch keys of several hash slots. Use a standalone server or a
Sentinel primary.
- **SQL expired-row cleanup** — `oauth2/store/sql` ships no expiry job:
rows stay until an operator deletes those past `expires_at` (see the
package documentation for a safe order).
rows — consumed and abandoned authorization codes included — stay until
an operator deletes those past `expires_at` (see the package
documentation for a safe order).
- **In-memory store growth** — `oauth2/storage/memory` never purges
anything: consumed and expired codes, expired tokens and revoked
families stay for the process lifetime. It is meant for development and
tests only.
- **PostgreSQL and MySQL are not exercised in CI** — the SQL store's tests
run on SQLite only; its PostgreSQL and MySQL guarantees (row locks,
READ COMMITTED on PostgreSQL, InnoDB on MySQL) are argued from their
Expand Down
48 changes: 48 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,54 @@ To migrate:
implicit) keep working until they expire. Flushing the old token rows
or keys is good hygiene: they can no longer be used.

### `oauth2`: consumed authorization codes are retained (#80)

`ConsumeAuthorizationCode` deleted a code as it consumed it, so a replay
looked exactly like an unknown code and nothing was left to tie the code
to the tokens its first exchange issued — while RFC 6749 §4.1.2 says those
tokens SHOULD be revoked. Stores now keep a consumed code — flagged
consumed, without the raw code — at least until it expires, and answer a
replay with an `*oauth2.CodeReplayError` carrying the retained record.

| Before | After |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `ConsumeAuthorizationCode` deletes the code | flags it consumed and keeps it at least until `ExpiresAt`: Redis until the key expires, SQL until your cleanup, memory for the process lifetime |
| a replay returns `ErrCodeAlreadyUsed`, like an unknown code | a replay returns an `*oauth2.CodeReplayError` (it matches `ErrCodeAlreadyUsed`) carrying the record |
| the memory store keeps the raw `Code` | no store keeps it |

To migrate:

1. **Custom stores.** Keep a consumed code, without its raw value, at
least until its `ExpiresAt`. Return the record, with a `nil` error, to
one caller only; return `nil` and `&oauth2.CodeReplayError{Record: rec}`
to every later or concurrent caller, and `oauth2.ErrCodeAlreadyUsed` for
an unknown code. Pass `storetest.RunConformance`.
2. **Code calling `ConsumeAuthorizationCode`** — decorators, custom
grants — tests `err` first and never issues tokens from a record that
comes with an error. A wrapper must propagate `err` unchanged or
wrapped with `%w`, which keeps the replay record reachable through
`errors.As`: normalizing it to `ErrCodeAlreadyUsed` silently disables
the revocation a replay triggers.
3. **SQL.** `oauth2_auth_codes` gains a `consumed` column, which `Migrate`
does not add to an existing table (it only creates missing tables).
Until it exists, every code exchange fails with `server_error`. Run, on
PostgreSQL, MySQL or SQLite:

```sql
ALTER TABLE oauth2_auth_codes ADD COLUMN consumed SMALLINT NOT NULL DEFAULT 0;
```

or drop the table and run `Migrate` again: a code only lives
`AuthorizeConfig.CodeTTL` (10 minutes by default). Consumed codes now
stay until your cleanup deletes the rows past `expires_at` (see the
`store/sql` package documentation); index `expires_at` through your
migration tool.
4. **Redis and memory** need nothing: a code payload without the new
`consumed` flag is an unconsumed code.
5. **Upgrade every instance at once.** An instance still running an
earlier build ignores the flag and would accept a code an upgraded
instance already consumed — a second use of the code.

### `oauth2`: `/revoke` answers 503 when a revocation may have failed (#82)

`/revoke` answered `200 OK` whatever happened, telling the client its token
Expand Down
20 changes: 11 additions & 9 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,15 +158,17 @@ The `oauth2` module is an authorization server, not just a provider:
prefix used in the metadata document is configurable via
`ServerConfig.RoutePrefix`.
- **`Storage`** — an interface with explicit atomicity contracts: a
single-use `ConsumeAuthorizationCode`, and token pairs persisted
atomically (`SaveTokenPair`, `RotateRefreshToken`) against one
authoritative state per token family, so that a revoked family
(`RevokeRefreshFamily`) can neither be used nor issued into — even before
its first token, and whatever races the revocation. Access and refresh
tokens share one backend; codes may live elsewhere. Three
implementations: in-memory, SQL (Postgres/MySQL/SQLite, row locks), Redis
(Lua scripts). All three pass the shared `oauth2/storetest` conformance
suite, which states the guarantees through the public API.
single-use `ConsumeAuthorizationCode` — consumed codes are retained until
they expire, so that a replayed code revokes the tokens issued from it —
and token pairs persisted atomically (`SaveTokenPair`,
`RotateRefreshToken`) against one authoritative state per token family, so
that a revoked family (`RevokeRefreshFamily`) can neither be used nor
issued into — even before its first token, and whatever races the
revocation. Access and refresh tokens share one backend; codes may live
elsewhere. Three implementations: in-memory, SQL (Postgres/MySQL/SQLite,
row locks), Redis (Lua scripts). All three pass the shared
`oauth2/storetest` conformance suite, which states the guarantees through
the public API.

Tokens and authorization codes are **never stored in cleartext** — the
store only ever sees a hash.
Expand Down
21 changes: 12 additions & 9 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,18 @@ The hook fires for:
`grant.Config.OnError` is the same hook for the errors a grant swallows
before returning — today, a family revocation that failed after a
refresh-token reuse (RFC 9700 §4.14.2), whether the grant found the
presented token consumed or the store reported the reuse while rotating.
The grant retries a revocation the store could not record, on a context
detached from the request, and reports one `server_error` per revocation
that still failed: `revoke refresh family failed after reuse detection`,
whose cause matches `oauth2.ErrTokenFamilyRevocationFailed` and names the
family with a conforming store. It does not match
`oauth2.ErrRefreshTokenReused`: the reuse itself is the `invalid_grant` the
server reports as usual. Errors a grant *returns* travel to the server and
reach `ServerConfig.OnError`, so wire both fields to the same sink.
presented token consumed or the store reported the reuse while rotating,
or after an authorization-code replay (RFC 6749 §4.1.2). These revocations
run on a context detached from the request, and the `refresh_token` grant
retries one the store could not record. The hook gets one `server_error`
per revocation that still failed — `revoke refresh family failed after
reuse detection`, or `revoke token family failed after authorization code
replay` — whose cause matches `oauth2.ErrTokenFamilyRevocationFailed` and
names the family with a conforming store. It does not match
`oauth2.ErrRefreshTokenReused` or `oauth2.ErrCodeAlreadyUsed`: the reuse or
replay itself is the `invalid_grant` the server reports as usual. Errors a
grant *returns* travel to the server and reach `ServerConfig.OnError`, so
wire both fields to the same sink.

The hook is purely observational: it MUST NOT influence the response, and it
runs synchronously on the request goroutine, so keep it fast. It receives no
Expand Down
48 changes: 40 additions & 8 deletions docs/security-considerations.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,36 @@ configurable clock skew, and resolves keys by `kid` against a JWKS provider
exactly one winner; the conformance suite checks it with 50 concurrent
consumers of one code and 30 concurrent rotations of one token, and races
revocations against rotation chains and issuance.
- **Authorization-code replay** — exchanging a consumed code is refused with
`invalid_grant`, and the tokens its first exchange issued are revoked:
access and refresh tokens, and every later rotation (RFC 6749 §4.1.2,
§10.5; RFC 9700 §4.2.4). Only a replay that would itself have been
accepted revokes: the code has not expired, and the client it was issued
to presents it with the same `redirect_uri` and PKCE verifier and may
still use `authorization_code` (OAuth 2.1 §4.1.3, §7.5.3). Anything else —
another client, a missing or wrong verifier, an unknown code — is refused
and revokes nothing: a public `client_id` is no secret, and revoking on
any replay would let whoever holds a leaked code cut the client off. The
grant derives the code's token family from the code before consuming it,
so the link takes no storage and a replay racing the first exchange cannot
leave its tokens active: one landing before that exchange persists its
tokens makes it answer `invalid_grant` and issue nothing; one landing
after revokes them. The first outcome holds while the store keeps the
family's revoked record (`oauth2.FamilyRevocationRetention` at least),
i.e. under the [deployment
requirements](#token-family-revocation-storage-guarantees): an evicted
Redis family key, or a SQL family row deleted before its `expires_at`,
lets the in-flight exchange recreate the family active. Stores keep a
consumed code, hashed and flagged, at least until it expires — Redis
expires the key with the code, SQL rows stay until your cleanup, the
memory store keeps them for good — so a replay is recognized for the
code's lifetime (`AuthorizeConfig.CodeTTL`, 10 minutes by default); a
later one is refused like any replay, and revokes nothing. A failed
revocation reaches `grant.Config.OnError`, and the next proven replay
retries it. A legitimate client retrying a lost response, or submitting a
code twice, revokes its own tokens: a code MUST NOT be reused (RFC 6749
§4.1.2). Access tokens validated offline are out of reach, see [Offline
JWT access tokens](#offline-jwt-access-tokens).
- **Profiles** — `Profile20BCP` (the default) follows the OAuth 2.0
Security BCP: it refuses the `implicit` grant. `Profile21Draft`
additionally refuses the `password` grant. Legacy grants are opt-in and
Expand Down Expand Up @@ -181,7 +211,7 @@ These guarantees rest on deployment assumptions, detailed in the package
documentation of [`store/sql`](../oauth2/store/sql/doc.go) and
[`store/redis`](../oauth2/store/redis/doc.go): every read reaches the
primary (no replica reads, no client-side caching), the store's PostgreSQL
token-family transactions run at READ COMMITTED (it pins the level) and
transactions run at READ COMMITTED (it pins the level) and
MySQL tables use InnoDB, Redis is a standalone server or a Sentinel primary
(not Cluster) running 6.0 or later with `maxmemory-policy noeviction`, no
family row or key is deleted before it expires, and durability is the
Expand All @@ -196,7 +226,9 @@ The store's revocation reaches every token looked up in the store —
`/introspect`, a resource server calling `LookupAccessToken` — but not a
resource server that verifies JWT access tokens offline, from their
signature and claims (`jwtsec.BearerVerifier`): such a token stays valid
until its `exp`, whatever happened to its family. The RFC 9068 payload the
until its `exp`, whatever happened to its family — including the tokens
revoked after an authorization-code replay: deleting or flagging store
records never invalidates a signature. The RFC 9068 payload the
`jwtsec` signer emits carries no family or token identifier (`jti`), so it
cannot be deny-listed today either. RFC 7009 §2.1 acknowledges such a
propagation delay and asks implementations to minimize it; its §3 leaves
Expand Down Expand Up @@ -258,12 +290,12 @@ span catalog.
- [ ] Give `ServerConfig.Storage` and every grant the same store, and wire
`ServerConfig.OnError` and `grant.Config.OnError` to the same sink:
failed family revocations are reported there.
- [ ] With `store/sql`, periodically delete rows past `expires_at` — never
a family row before it — in the deadlock-free order the package
documentation gives; with `store/redis`, run Redis 6.0+ standalone or
behind Sentinel with `maxmemory-policy noeviction`, and read from the
primary only. A revoked family whose state is lost before it expires
can be revived.
- [ ] With `store/sql`, periodically delete rows past `expires_at` —
consumed authorization codes included, never a family row before
it — in the deadlock-free order the package documentation gives;
with `store/redis`, run Redis 6.0+ standalone or behind Sentinel with
`maxmemory-policy noeviction`, and read from the primary only. A
revoked family whose state is lost before it expires can be revived.
- [ ] Keep JWT access tokens short-lived: a store revocation does not
reach resource servers that verify them offline.
- [ ] Serve over HTTPS so `Secure` cookies and bearer tokens are protected.
Expand Down
3 changes: 3 additions & 0 deletions examples/oauth2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ curl -i -u 5cc06c3b-5755-4229-958c-a515a245aaeb:WTvuAztPD2XBauomleRzGFYuZawS07Ym
http://localhost:1337/oauth2/token
```

A code is single-use: running the same exchange again is refused with
`invalid_grant` and revokes the tokens the first exchange issued.

## What this example does NOT cover

- JWT-formatted access tokens (`jwt.OAuth2AccessTokenSigner` adapter wires
Expand Down
59 changes: 59 additions & 0 deletions oauth2/code_replay_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright 2026 Hyperscale. All rights reserved.
// Use of this source code is governed by a MIT
// license that can be found in the LICENSE file.

package oauth2_test

import (
"errors"
"fmt"
"testing"

"github.com/hyperscale-stack/security"
"github.com/hyperscale-stack/security/oauth2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestCodeReplayErrorMatchesCodeAlreadyUsed pins what a store's replay
// error looks like to its callers, bare or wrapped with %w: the
// invalid_grant ErrCodeAlreadyUsed for errors.Is, IsCode and the error
// text, and the retained record for errors.As.
func TestCodeReplayErrorMatchesCodeAlreadyUsed(t *testing.T) {
t.Parallel()

record := &oauth2.AuthorizationCode{CodeHash: "hash", ClientID: "client"}
replay := &oauth2.CodeReplayError{Record: record}

for name, err := range map[string]error{
"bare": replay,
"wrapped": fmt.Errorf("store: consume: %w", replay),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()

require.ErrorIs(t, err, oauth2.ErrCodeAlreadyUsed)
require.ErrorIs(t, err, security.ErrInvalidCredentials, "the core sentinel stays reachable")
assert.Equal(t, oauth2.CodeInvalidGrant, oauth2.IsCode(err))
assert.Contains(t, err.Error(), oauth2.ErrCodeAlreadyUsed.Error())

var got *oauth2.CodeReplayError

require.ErrorAs(t, err, &got)
assert.Same(t, record, got.Record)
})
}

assert.Equal(t, oauth2.ErrCodeAlreadyUsed.Error(), replay.Error())
assert.Same(t, oauth2.ErrCodeAlreadyUsed, errors.Unwrap(replay))
}

// TestCodeAlreadyUsedIsNoReplay: the bare sentinel — what a store returns
// for an unknown code — carries no record to revoke from.
func TestCodeAlreadyUsedIsNoReplay(t *testing.T) {
t.Parallel()

var replay *oauth2.CodeReplayError

assert.False(t, errors.As(oauth2.ErrCodeAlreadyUsed, &replay))
}
Loading
Loading