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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ legacy packages (`authentication/`, `authorization/`, the in-tree
- **Documentation**: `docs/architecture.md`, `docs/observability.md`,
`docs/security-considerations.md`, `docs/migration-from-v0.md`, and a
refreshed `README.md`.
- **OAuth2 introspection policy**: `ServerConfig.IntrospectionPolicy`
(`oauth2.IntrospectionPolicy`) decides which active tokens each caller of
`/introspect` may see — the place to enforce resource and tenant
boundaries (RFC 7662 §4). It receives the request context, the
authenticated confidential caller and an `oauth2.IntrospectedToken` (its
`oauth2.TokenKind`, client, subject, scope, audience, issuance and expiry —
never the token value). A denial answers a bare `{"active":false}`, the
same bytes as an unknown token; a policy error fails closed the same way
and reaches `OnError` as a `server_error`. Optional: without a policy, any
authenticated confidential client may introspect any token.

### Changed

Expand Down Expand Up @@ -147,3 +157,18 @@ legacy packages (`authentication/`, `authorization/`, the in-tree
it fails with `server_error` before any token is minted or persisted.
Non-rotating refresh tokens remain an explicit `Profile20` choice
(`RotateRefreshTokens: false`).
- The OAuth2 `/introspect` endpoint now answers only clients whose
`Type()` is `oauth2.ClientConfidential` (RFC 7662 §2.1 / §4, RFC 6749
§2.2, §2.3). It used to answer any client the shared `ClientAuth` chain
authenticated, so anyone knowing a public `client_id` — which is not a
secret — could read the validity and claims of any token they held,
refresh tokens included: through the `none` method, or through
`client_secret_basic` / `client_secret_post` with the client's registered
secret (Basic: even an empty one), whether or not `none` was registered.
Public clients, whatever the method, and untyped clients (`Type() == ""`)
authenticated through Basic or Post — `none` already refused them — now
get `401 invalid_client`. This breaks deployments whose applications, or
resource servers registered without a type, call `/introspect`. `/token`
and `/revoke` are unchanged. The `none` method's `error_description` for
a non-public client no longer contains a double quote, which RFC 6749
§5.2 forbids. See [MIGRATION.md](MIGRATION.md).
81 changes: 81 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,84 @@ Every sub-module declares `replace github.com/hyperscale-stack/security => ../`
(or `=> ../../` for the SQL/Redis sub-modules) so local development and CI
work without published versions. Releases are cut per module with
`module/vX.Y.Z` tags.

## Breaking changes before the first v2 release

The v2 stack is not tagged yet, but code tracking `master` may already rely
on its behavior. Each entry below states what changed and how to adapt.

### `oauth2`: `/introspect` only answers confidential clients (#81)

`/introspect` used to answer any caller the shared `ServerConfig.ClientAuth`
chain authenticated. RFC 7662 §2.1 / §4 require the authorization server to
authenticate the protected resources calling it, but a `client_id` alone is
not a credential (RFC 6749 §2.2), and the authorization server MUST NOT rely
on a public client's authentication to identify it (RFC 6749 §2.3). Yet a
public client's `client_id`, shipped inside every copy of a SPA or mobile
app, was enough for anyone to read the validity and claims of a token they
held, refresh tokens included: through the `none` method, or through
`client_secret_basic` / `client_secret_post` with the client's registered
secret (Basic: even an empty one), whether or not `none` was registered.
Untyped clients authenticating through Basic or Post were answered too.

The endpoint now answers only callers whose `Client.Type()` is
`oauth2.ClientConfidential`: a caller whose credentials are valid but whose
type is anything else gets `401 invalid_client`
(`introspection requires confidential client authentication`):

| Caller at `/introspect` | Before | After |
| -------------------------------------------------------------------------------------------- | ------------------- | -------------------- |
| Public client via `none` (`client_id` only) | `200`, token claims | `401 invalid_client` |
| Public client via `client_secret_basic` / `_post` with its secret (Basic: even an empty one) | `200`, token claims | `401 invalid_client` |
| Untyped client via `client_secret_basic` / `_post` | `200`, token claims | `401 invalid_client` |
| Confidential client via `client_secret_basic` / `_post` | `200`, token claims | unchanged (see 3.) |

`/token` and `/revoke` are unchanged: a public client keeps running the
authorization-code + PKCE flow, refreshing and revoking its own tokens with
`none`.

To migrate:

1. Register every resource server that calls `/introspect` as a confidential
client with a secret, authenticated with `client_secret_basic` or
`client_secret_post`. An untyped client — such as an
`oauth2.DefaultClient` without `TypeValue` — must now set
`TypeValue: oauth2.ClientConfidential` to keep introspecting.
2. Stop calling `/introspect` from public applications: read `expires_in`
and `scope` from the `/token` response, or ask a confidential backend.
3. When several resource servers or tenants share the authorization server,
set `ServerConfig.IntrospectionPolicy`: without one, any authenticated
confidential client may introspect any token. RFC 7662 §4 says protected
resources SHOULD be specifically authorized, and when a token can be used
only at certain resource servers, the authorization server MUST determine
whether it can be used at the one making the call. A denial answers a bare
`{"active":false}`, the same bytes as an unknown token; a policy error
fails closed the same way and reaches `OnError`, so wire both. A typical
policy discloses access tokens to the resource server they are meant for,
and refresh tokens to none:

```go
srv, err := oauth2.NewServer(oauth2.ServerConfig{
// ...
OnError: onError, // an oauth2.ErrorHook: policy failures are reported here
IntrospectionPolicy: func(_ context.Context, caller oauth2.Client, tok oauth2.IntrospectedToken) (bool, error) {
// A refresh token is meant for its client alone and carries no
// audience: never disclose it to a resource server.
if tok.Kind != oauth2.TokenKindAccess {
return false, nil
}

// audienceOf is your registry: resource server -> its audience.
// An empty audience, on either side, fails closed.
return tok.Audience != "" && tok.Audience == audienceOf(caller.ID()), nil
},
})
```

Do not reduce the check to `caller.ID() == tok.ClientID`: the resource
server introspecting a token is usually not the client it was issued to,
so that rule refuses the legitimate calls without enforcing any resource
or tenant boundary. The policy only sees active tokens, only after the
caller authenticated as a confidential client, and gets the request
context; see the `oauth2.IntrospectionPolicy` godoc for the full
contract.
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,12 @@ The `oauth2` module is an authorization server, not just a provider:
`refresh_token` (rotation + reuse detection), plus the opt-in legacy
`password` grant (`grant.NewLegacyPassword`, refused outside `Profile20`).
- **Client authentication** — `client_secret_basic`, `client_secret_post`,
`none` (public clients, PKCE required).
`none` (public clients, PKCE required). `/introspect` answers
confidential clients only.
- **Endpoints** — `/authorize` (RFC 6749 §3.1, `authorization_code` and the
opt-in legacy `implicit` flow, with an application-supplied consent hook),
`/token`, `/revoke` (RFC 7009), `/introspect` (RFC 7662),
`/token`, `/revoke` (RFC 7009), `/introspect` (RFC 7662, with an optional
`IntrospectionPolicy` deciding which tokens each caller may see),
`/.well-known/oauth-authorization-server` (RFC 8414). The endpoint path
prefix used in the metadata document is configurable via
`ServerConfig.RoutePrefix`.
Expand Down
6 changes: 5 additions & 1 deletion docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ The hook fires for:
`/introspect`, metadata) and every error redirected back to the client by
`/authorize`, including the pre-redirect refusals answered with a bare
400 (unknown client, unregistered `redirect_uri`);
- the best-effort revocations `/revoke` swallows to honour RFC 7009 §2.2.
- the best-effort revocations `/revoke` swallows to honour RFC 7009 §2.2;
- an `IntrospectionPolicy` that failed: `/introspect` withholds the token
behind a bare `{"active":false}` and reports a `server_error`
(`introspection policy failed`, the policy's error as cause). A plain
refusal by the policy is an expected outcome and is not reported.

`grant.Config.OnError` is the same hook for the errors a grant swallows
before returning — today, a family revocation that failed during BCP §8.10.3
Expand Down
28 changes: 27 additions & 1 deletion docs/security-considerations.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,30 @@ configurable clock skew, and resolves keys by `kid` against a JWKS provider
refused outright under the stricter profile.
- **Client authentication** — `client_secret_basic` / `client_secret_post`
compare secrets in constant time. Public clients use `none` and MUST use
PKCE.
PKCE. `/introspect` answers `ClientConfidential` callers only: a public
client (whatever the method, `none` included) or an untyped client gets
`401 invalid_client`.
- **Token introspection** — RFC 7662 §4 requires the authorization server
to authenticate the protected resources calling `/introspect`. A public
client cannot authenticate: its `client_id` ships inside every copy of the
app and RFC 6749 §2.2 forbids using it alone, and the authorization server
MUST NOT rely on a public client's authentication — even with a secret —
to identify it (RFC 6749 §2.3). Accepting one would let anyone read the
validity and claims of a token they held — through `none`, or through
Basic/Post with the client's registered secret (Basic: even an empty
one) — so `/introspect` refuses every caller that is not a confidential
client. By default, any authenticated confidential client may then
introspect any token. RFC 7662 §4 adds that protected resources SHOULD be
specifically authorized, and that when a token can be used only at certain
resource servers the authorization server MUST determine whether it can be
used at the caller: that decision is `ServerConfig.IntrospectionPolicy`,
which sees the authenticated caller and the token's metadata before
anything is disclosed. Refuse refresh tokens to resource servers — they are
meant for the client alone, and a resource server that ignores
`token_type` could accept one as a bearer token. A withheld token and a
failed policy answer the same bytes as an unknown token
(`{"active":false}`), so the response discloses nothing (its latency may
still differ); a policy failure is reported to `OnError`.

## Sessions

Expand Down Expand Up @@ -126,6 +149,9 @@ span catalog.
- [ ] Keep the JWT allowlist asymmetric unless you truly need HMAC.
- [ ] Use `Profile20BCP` or stricter; do not enable the `implicit` /
`password` legacy grants without a documented reason.
- [ ] Register the resource servers calling `/introspect` as confidential
clients, and set `ServerConfig.IntrospectionPolicy` when several
resource servers or tenants share the authorization server.
- [ ] Serve over HTTPS so `Secure` cookies and bearer tokens are protected.
- [ ] Supply at least two session keys so rotation is possible without
invalidating live sessions.
Expand Down
6 changes: 5 additions & 1 deletion oauth2/clientauth/none.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import (
//
// The server MUST reject confidential clients trying to use "none"; the
// grant handler enforces PKCE separately.
//
// A client_id alone is not a credential (RFC 6749 §2.2): the server's
// /introspect endpoint, which answers confidential clients only, refuses
// every caller authenticated this way.
func NewNone() ClientAuthenticator { return noneAuth{} }

type noneAuth struct{}
Expand Down Expand Up @@ -56,7 +60,7 @@ func (noneAuth) Authenticate(ctx context.Context, r *http.Request, store oauth2.
}

if client.Type() != oauth2.ClientPublic {
return nil, oauth2.ErrInvalidClient.WithDescription(`"none" reserved to public clients`)
return nil, oauth2.ErrInvalidClient.WithDescription("none method reserved to public clients")
}

if !allowsMethod(client, "none") {
Expand Down
9 changes: 7 additions & 2 deletions oauth2/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
// endpoint (authorization_code, and the opt-in legacy implicit flow);
// TokenHandler, RevokeHandler, IntrospectHandler and MetadataHandler
// cover the remaining RFC endpoints.
// - IntrospectHandler answers confidential clients only (a public
// client's client_id is no credential), and the optional
// ServerConfig.IntrospectionPolicy decides which active tokens each
// caller may see, enforcing resource and tenant boundaries (RFC 7662
// §4).
// - Grants implement authorization_code (with PKCE), client_credentials
// and refresh_token. The legacy password grant (grant.NewLegacyPassword)
// is opt-in and refused outside Profile20.
Expand All @@ -24,8 +29,8 @@
// to guarantee single-use semantics and reuse-detection.
// - ServerConfig.OnError (and grant.Config.OnError) observe the errors the
// protocol hides: the cause of a server_error, which never reaches the
// wire, and the revocations RFC 7009 §2.2 requires to answer 200 OK
// regardless. See ErrorHook.
// wire, the revocations RFC 7009 §2.2 requires to answer 200 OK
// regardless, and a failed IntrospectionPolicy. See ErrorHook.
//
// Allowed dependencies:
// - github.com/hyperscale-stack/security (core)
Expand Down
3 changes: 2 additions & 1 deletion oauth2/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import "context"
// wire response deliberately drops it.
// - the errors the server swallows to stay protocol-compliant, such as a
// best-effort revocation that failed (RFC 7009 §2.2 mandates 200 OK
// whatever happened).
// whatever happened) or an [IntrospectionPolicy] that failed (the token
// is withheld behind a bare {"active":false}, RFC 7662 §2.2).
//
// The hook is a pure observability sink: it MUST NOT influence the
// response. It runs synchronously on the request goroutine, so a slow
Expand Down
Loading
Loading