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: 34 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree
key rotation, `alg=none` and algorithm-confusion defenses, and a
`bearer.TokenVerifier` adapter.
- **OAuth2 server** (`oauth2`): `Profile` (2.0 / 2.0-BCP / 2.1-draft),
enforced at runtime on the grants (PKCE required, `plain` PKCE refused
under BCP / 2.1). Grants: `authorization_code` (PKCE), `client_credentials`,
enforced at runtime on the grants (PKCE required, `plain` PKCE refused,
refresh-token rotation forced under BCP / 2.1). Grants:
`authorization_code` (PKCE), `client_credentials`,
`refresh_token` (rotation + reuse detection), and the opt-in legacy
`password` grant (`grant.NewLegacyPassword`, refused outside `Profile20`).
`client_secret_basic`/`_post`/`none` client authentication. Endpoints:
Expand All @@ -65,6 +66,15 @@ legacy packages (`authentication/`, `authorization/`, the in-tree
never puts on the wire — plus the errors it swallows on purpose, such as a
best-effort revocation (RFC 7009 §2.2) or a family revocation that failed
during reuse detection. Purely observational: the responses are unchanged.
- **OAuth2 profile consistency checks**: `oauth2.ProfileValidator`, an
optional `Grant` capability: `oauth2.NewServer` asks every registered
grant implementing it whether it can honor the active `Profile`, and
refuses to build the server otherwise; the shipped `refresh_token` grant
implements it. `grant.ErrRotationRequiresGenerator` reports a
`refresh_token` grant that has to rotate refresh tokens but has no
`grant.Config.RefreshTokens` generator to mint the replacement — returned
by `NewServer`, and the cause of the `server_error` such a grant fails
with at runtime, before any token is minted or persisted.
- **Observability**: OpenTelemetry spans emitted directly by the core,
`httpsec`, `grpcsec`, `connectrpcsec`, `jwtsec`, and `session`. See
[docs/observability.md](docs/observability.md).
Expand Down Expand Up @@ -101,6 +111,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree
1h TTL is advertised as `3600`, not `3599`) and an already-expired token
drops the field rather than sending the negative lifetime RFC 6749 §5.1
does not allow.
- A rotated OAuth2 refresh token keeps the scope of the token it replaces
(RFC 6749 §6); narrowing the scope of a refresh only narrows the issued
access token. The replacement used to inherit the narrowed scope, so the
grant shrank permanently and a later request for the original scope was
refused with `invalid_scope`.

### Removed

Expand All @@ -115,3 +130,20 @@ legacy packages (`authentication/`, `authorization/`, the in-tree
a fixed, generic string per error code, so internal context (timestamps,
package and authenticator names, consumer-supplied `TokenVerifier`/store
errors) can no longer leak to clients.
- The OAuth2 `refresh_token` grant now enforces the refresh-token rotation
that `Profile20BCP` (the default) and `Profile21Draft` mandate (RFC 9700
§4.14.2) — for every client, stricter than RFC 9700 §2.2.2, which only
requires it (or sender-constrained refresh tokens) for public clients —
whatever `grant.Config.RotateRefreshTokens` says: every exchange consumes
the presented refresh token and returns a new one, and replaying a
consumed token is refused with `invalid_grant` and revokes the whole
family. The zero configuration used to let the same refresh token be
replayed until it expired. Clients MUST keep the refresh token returned
by each exchange (RFC 6749 §6). A grant that has to rotate but has no
`grant.Config.RefreshTokens` generator — including under `Profile20`
with `RotateRefreshTokens` set, which used to skip the rotation
silently — is refused by `oauth2.NewServer`, and should it run anyway
(called directly, or behind a decorator that hides it from `NewServer`)
it fails with `server_error` before any token is minted or persisted.
Non-rotating refresh tokens remain an explicit `Profile20` choice
(`RotateRefreshTokens: false`).
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ The `oauth2` module is an authorization server, not just a provider:

- **`Profile`** — `Profile20`, `Profile20BCP` (default), `Profile21Draft`.
The profile gates which grants and PKCE methods are allowed, and is
enforced at runtime on the grants — PKCE is required and the `plain`
transformation refused under BCP / 2.1.
enforced at runtime on the grants — PKCE is required, the `plain`
transformation refused, and refresh-token rotation forced under BCP / 2.1.
- **Grants** — `authorization_code` (PKCE), `client_credentials`,
`refresh_token` (rotation + reuse detection), plus the opt-in legacy
`password` grant (`grant.NewLegacyPassword`, refused outside `Profile20`).
Expand Down
21 changes: 17 additions & 4 deletions docs/security-considerations.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,23 @@ configurable clock skew, and resolves keys by `kid` against a JWKS provider
- **PKCE** — `authorization_code` requires PKCE. `S256` is the only method
allowed under `Profile21Draft`; `plain` is accepted (with a warning) only
under the looser profiles.
- **Refresh-token rotation** — every refresh issues a new token and
invalidates the old one. Re-use of an already-rotated token is treated as
theft: the whole token family is revoked (`RotateRefreshToken` returns
`ErrRefreshTokenReused`).
- **Refresh-token rotation** — mandatory under `Profile20BCP` (the default)
and `Profile21Draft`, whatever `grant.Config.RotateRefreshTokens` says:
every refresh exchange consumes the presented token and issues a new one
in the same family (RFC 9700 §4.14.2). The profiles apply it to every
client, stricter than RFC 9700 §2.2.2, which requires rotation — or
sender-constrained refresh tokens, which this library does not issue —
for public clients only. Under these profiles — and under `Profile20`
when `RotateRefreshTokens` is set — `NewServer` refuses a
`refresh_token` grant without a `RefreshTokens` generator, and the grant
itself fails with `server_error` before minting anything when it cannot
rotate. Re-use of an already-rotated token is treated as theft: the
whole family is revoked (`ErrRefreshTokenReused`). Clients MUST keep the
refresh token returned by each exchange (RFC 6749 §6) and must not
refresh concurrently with the same token, or they lose the family. Under
`Profile20` rotation is opt-in (`RotateRefreshTokens`); without it a
refresh token stays reusable until it expires, which RFC 9700 §2.2.2
forbids for public clients.
- **Token storage** — access tokens, refresh tokens, and authorization
codes are stored **hashed only** (SHA-256, via `oauth2.HashToken`). The
store never sees cleartext, so a database compromise does not yield
Expand Down
18 changes: 10 additions & 8 deletions examples/oauth2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,17 @@ func buildServer() (http.Handler, error) {
},
}}

// Authorization server.
// Authorization server. Profile20BCP mandates refresh-token rotation:
// the refresh_token grant always mints the replacement with
// RefreshTokens, and NewServer refuses the wiring without it.
// RotateRefreshTokens only matters under the legacy Profile20.
gcfg := grant.Config{
Storage: store,
AccessTokens: token.NewOpaque(32),
RefreshTokens: token.OpaqueRefreshAdapter{Opaque: token.NewOpaque(32)},
AccessTTL: time.Hour,
RefreshTTL: 24 * time.Hour,
RotateRefreshTokens: true,
OnError: logOAuthError,
Storage: store,
AccessTokens: token.NewOpaque(32),
RefreshTokens: token.OpaqueRefreshAdapter{Opaque: token.NewOpaque(32)},
AccessTTL: time.Hour,
RefreshTTL: 24 * time.Hour,
OnError: logOAuthError,
}

srv, err := oauth2.NewServer(oauth2.ServerConfig{
Expand Down
40 changes: 39 additions & 1 deletion examples/oauth2/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,48 @@ func TestExampleOAuth2AuthorizationCodeFlow(t *testing.T) {
require.NoError(t, err)

var tok struct {
AccessToken string `json:"access_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&tok))
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.NotEmpty(t, tok.AccessToken)
require.NotEmpty(t, tok.RefreshToken)

// 4. Profile20BCP mandates refresh-token rotation: the refresh exchange
// returns a replacement refresh token...
resp = refreshExchange(t, client, srv.URL, tok.RefreshToken)

var rotated struct {
RefreshToken string `json:"refresh_token"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rotated))
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, rotated.RefreshToken)
assert.NotEqual(t, tok.RefreshToken, rotated.RefreshToken)

// 5. ...and replaying the consumed one is refused.
resp = refreshExchange(t, client, srv.URL, tok.RefreshToken)
_ = resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}

// refreshExchange POSTs a refresh_token grant for raw to the demo /token
// endpoint, authenticated as the demo client.
func refreshExchange(t *testing.T, client *http.Client, baseURL, raw string) *http.Response {
t.Helper()

form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {raw}}

req, err := http.NewRequest(http.MethodPost, baseURL+"/oauth2/token", strings.NewReader(form.Encode()))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(demoClientID, demoClientSecret)

resp, err := client.Do(req)
require.NoError(t, err)

return resp
}
3 changes: 2 additions & 1 deletion oauth2/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
// - Server aggregates Profile, Storage, Grants, ClientAuth, IssuerResolver.
// - Profile selects the security baseline (OAuth2.0, OAuth2.0-BCP,
// OAuth2.1-draft). BCP is the recommended default and is enforced at
// runtime on the grants (PKCE required, "plain" PKCE refused).
// runtime on the grants (PKCE required, "plain" PKCE refused,
// refresh-token rotation forced).
// - Endpoints: AuthorizeHandler runs the RFC 6749 §3.1 authorization
// endpoint (authorization_code, and the opt-in legacy implicit flow);
// TokenHandler, RevokeHandler, IntrospectHandler and MetadataHandler
Expand Down
27 changes: 18 additions & 9 deletions oauth2/grant/grant.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
// - authorization_code (with PKCE; PKCE is mandatory in
// [oauth2.Profile20BCP] and [oauth2.Profile21Draft])
// - client_credentials
// - refresh_token (with rotation + reuse detection)
// - refresh_token (rotation + reuse detection; rotation is mandatory in
// [oauth2.Profile20BCP] and [oauth2.Profile21Draft])
//
// Legacy grants (password, implicit) live behind explicit opt-in helpers
// and are refused outside [oauth2.Profile20].
Expand All @@ -31,21 +32,29 @@ type Config struct {
Storage oauth2.Storage
// AccessTokens issues access tokens (opaque or JWT).
AccessTokens token.AccessTokenGenerator
// RefreshTokens issues refresh tokens. Optional — when nil, the
// grant emits no refresh token.
// RefreshTokens issues refresh tokens. Optional for authorization_code
// and password, which emit no refresh token without it. Required by
// refresh_token whenever it rotates — always under
// [oauth2.Profile20BCP] and [oauth2.Profile21Draft] — since the
// replacement is minted with it; see [ErrRotationRequiresGenerator].
RefreshTokens token.RefreshTokenGenerator
// AccessTTL is the access-token expiry window.
AccessTTL time.Duration
// RefreshTTL is the refresh-token expiry window. Honored when
// RefreshTokens is non-nil.
RefreshTTL time.Duration
// RequirePKCE forces PKCE on authorization_code; default in BCP/21
// profiles. The authorization_code grant honors this independently
// of public-vs-confidential client type.
// RequirePKCE forces PKCE on authorization_code, independently of
// public-vs-confidential client type. [oauth2.Profile20BCP] and
// [oauth2.Profile21Draft] require PKCE whatever this field says.
RequirePKCE bool
// RotateRefreshTokens emits a fresh refresh token on every
// /token?grant_type=refresh_token call and marks the old one
// consumed; reuse triggers family revocation. Default true in BCP/21.
// RotateRefreshTokens makes refresh_token issue a fresh refresh token
// on every exchange and atomically mark the presented one consumed;
// replaying a consumed token revokes the whole family (RFC 9700
// §4.14.2). [oauth2.Profile20BCP] and [oauth2.Profile21Draft] mandate
// rotation whatever this field says, so it only matters under
// [oauth2.Profile20], where false keeps the legacy behavior: the
// refresh token stays reusable until it expires. Rotation requires
// RefreshTokens.
RotateRefreshTokens bool
// OnError, when set, observes the errors a grant swallows to keep the
// protocol response intact — today, a family revocation that failed
Expand Down
75 changes: 58 additions & 17 deletions oauth2/grant/grant_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/hyperscale-stack/security/oauth2"
"github.com/hyperscale-stack/security/oauth2/grant"
"github.com/hyperscale-stack/security/oauth2/token"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -365,6 +366,23 @@ func TestRefreshTokenNarrowsScope(t *testing.T) {
resp, err := g.Handle(context.Background(), grant.Request{Client: newClient(), Form: form, Now: time.Now()})
require.NoError(t, err)
assert.Equal(t, "read:mail", resp.Scope)
assert.Equal(t, "read:mail", resp.Pair.Access.Scope)

// RFC 6749 §6: narrowing only affects the access token; the rotated
// refresh token keeps the scope of the one it replaces.
require.NotNil(t, resp.Pair.Refresh)
assert.Equal(t, "read:mail write:mail", resp.Pair.Refresh.Scope)

stored, err := store.LookupRefreshToken(context.Background(), resp.Pair.Refresh.TokenHash)
require.NoError(t, err)
assert.Equal(t, "read:mail write:mail", stored.Scope)

// So the next exchange without a scope gets the whole original grant back.
next, err := g.Handle(context.Background(), grant.Request{
Client: newClient(), Form: url.Values{"refresh_token": {resp.Pair.Refresh.Token}}, Now: time.Now(),
})
require.NoError(t, err)
assert.Equal(t, "read:mail write:mail", next.Scope)
}

func TestRefreshTokenRefusesBroadenedScope(t *testing.T) {
Expand All @@ -386,24 +404,47 @@ func TestRefreshTokenRefusesBroadenedScope(t *testing.T) {
assert.Equal(t, oauth2.CodeInvalidScope, oauth2.IsCode(err))
}

func TestRefreshTokenWithoutRotation(t *testing.T) {
func TestRefreshTokenWithoutRotationUnderProfile20(t *testing.T) {
t.Parallel()

store := newStore()
seedRefresh(t, store, "static-rt", "read:mail", time.Now().Add(time.Hour))

// RotateRefreshTokens defaults to false here: the grant issues a new
// access token but no replacement refresh token.
g := grant.NewRefreshToken(grant.Config{
Storage: store, AccessTokens: newAccessGen(), RefreshTokens: newRefreshGen(),
AccessTTL: time.Hour, RefreshTTL: 24 * time.Hour, RotateRefreshTokens: false,
})

form := url.Values{}
form.Set("refresh_token", "static-rt")
cases := []struct {
name string
gen token.RefreshTokenGenerator
}{
{"with a refresh generator", newRefreshGen()},
{"without a refresh generator", nil},
}

resp, err := g.Handle(context.Background(), grant.Request{Client: newClient(), Form: form, Now: time.Now()})
require.NoError(t, err)
assert.NotEmpty(t, resp.Pair.Access.Token)
assert.Nil(t, resp.Pair.Refresh, "no rotation -> no new refresh token")
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

store := newStore()
seedRefresh(t, store, "static-rt", "read:mail", time.Now().Add(time.Hour))

// Rotation is opt-in under Profile20 only: with RotateRefreshTokens
// false the grant issues a new access token but no replacement
// refresh token, and the presented one stays usable.
g := grant.NewRefreshToken(grant.Config{
Storage: store, AccessTokens: newAccessGen(), RefreshTokens: tc.gen,
AccessTTL: time.Hour, RefreshTTL: 24 * time.Hour, RotateRefreshTokens: false,
})

form := url.Values{}
form.Set("refresh_token", "static-rt")

req := grant.Request{Client: newClient(), Form: form, Now: time.Now(), Profile: oauth2.Profile20}

for range 2 {
resp, err := g.Handle(context.Background(), req)
require.NoError(t, err)
assert.NotEmpty(t, resp.Pair.Access.Token)
assert.Nil(t, resp.Pair.Refresh, "no rotation -> no new refresh token")
}

rt, err := store.LookupRefreshToken(context.Background(), oauth2.HashToken(nil, "static-rt"))
require.NoError(t, err)
assert.False(t, rt.Consumed, "a non-rotating refresh never consumes the token")
})
}
}
Loading
Loading