Feature/refactoring#32
Merged
Merged
Conversation
This is Phase 0 of the architecture refactor. It fixes the bugs that block
any further structural work and prepares the API for the new core landing
in Phase 2.
Bug fixes:
- authentication/handler.go: stop iterating providers after the first
successful Authenticate (previously a later supported provider could
overwrite the authenticated state silently).
- authentication/provider/oauth2: authenticateByClient surfaces secret
mismatches as security.ErrClientSecretMismatch instead of returning nil
and leaving the credential unauthenticated; same for clients that don't
implement ClientSecretMatcher.
- authentication/http_basic_filter.go: fix "deocde" -> "decode" log typo.
- example/oauth2: rebuild against the actual API (NewOAuth2AuthenticationProvider
takes 6 args), drop missing gorilla/mux dep, ship a runnable demo with
ReadHeaderTimeout, Content-Type, and a probe-only README.
New API surface (additive, no breaking changes to v0 users):
- security.SecurityError marker interface.
- security.{ErrInvalidCredentials, ErrClientSecretMismatch, ErrTokenExpired,
ErrTokenNotFound, ErrUnsupportedCredential} sentinels.
- security.Clock interface + SystemClock + DefaultClock.
- oauth2.ErrTokenExpired now wraps security.ErrTokenExpired via fmt.Errorf
%w so errors.Is works transparently across packages.
- authentication.Handler maps typed errors to HTTP status via errors.Is.
Tests:
- 4 sentinel tests + Clock tests at the root.
- Handler regression suite: first-supported-wins, fallthrough on
unsupported, typed-error mapping (table-driven incl. wrapped errors).
- OAuth2 client_secret_mismatch + non-matcher client coverage.
- IsExpiredAt deterministic table-driven tests on AccessInfo and
AuthorizeInfo.
- All `go test -race ./...` green incl. internal/integrations.
- golangci-lint clean on the delta (3 pre-existing gosec G117 on
Secret/AccessToken/RefreshToken fields tracked for Phase 7).
LIMITATIONS.md documents the gaps that this phase does not address, each
mapped to the responsible upcoming phase (2/3/4/5/6/7/8/9/10).
This is Phase 1 of the architecture refactor. It scaffolds the workspace
that will host the transport-, scheme- and storage-isolated sub-modules,
without yet moving any production code into them.
Workspace structure (go.work, all live behind a single repo):
. core (legacy MVP + new Phase-0 sentinels)
./http httpsec net/http adapter -> Phase 3
./grpc grpcsec gRPC interceptors -> Phase 9
./basic basic HTTP Basic auth -> Phase 4
./bearer bearer Bearer token auth -> Phase 4
./jwt jwtsec JWT sign / verify / JWKS -> Phase 6
./session session Cookie sessions + CSRF -> Phase 10
./oauth2 oauth2 New OAuth2 server -> Phase 7
./oauth2/store/sql sqlstore Production SQL store -> Phase 8
./oauth2/store/redis redisstore Production Redis -> Phase 8
./examples examples Use-case demos -> Phase 11
./example/oauth2 legacy client_creds demo (kept until Phase 11)
Each sub-module is empty except for go.mod (with a local replace -> ../)
and a doc.go stating its mission, allowed deps and target phase. The
new core (Authentication, Engine, Voter, ...) lands in Phase 2 in the
root module; the legacy authentication/, authorization/ trees stay
deprecated-in-place until the end of Phase 7.
Notable internal moves:
- http/header/ -> internal/header/
The Authorization-header helper was previously exported under the
same import path that the new httpsec module wants. Hiding it under
internal/ frees the path and keeps the helper usable from the core's
legacy filters. Phase 3 will re-expose a clean public version via
the httpsec module.
Build / lint / test infrastructure:
- Makefile rewritten to discover every go.mod and iterate (build, test,
lint, tidy, bench, sync). Targets aggregate per-module coverage into
a single build/coverage.out for Coveralls.
- .github/workflows/go.yml drives the new Makefile targets.
- Shared .golangci.yml used everywhere via --config $(repo root).
Hygiene:
- 3 pre-existing gosec G117 warnings on oauth2.{Secret,AccessToken,
RefreshToken} are annotated //nolint:gosec with an explicit Phase-7
cleanup pointer (these fields go away in Phase 7's rewrite). This
unblocks `make lint` across the workspace.
Verification:
- `make sync && make build && make test && make lint` all green.
- Legacy internal/integrations tests still pass after the
http/header -> internal/header move.
This is Phase 2 of the architecture refactor. It lands the new core API
inside the root `package security` and marks the legacy authentication/,
authentication/credential/, authentication/provider/{dao,oauth2}/ and
authorization/ packages as Deprecated.
New core types (all in the root module):
- Principal identity carrying a stable Subject().
- Authentication immutable security context (replaces the legacy
mutable Credential / interface{} model).
- Anonymous() / AnonymousPrincipal: singletons for "no credential" paths.
- Carrier transport-agnostic header/cookie/query reader-
writer (net/http and gRPC adapters land in
Phases 3 and 9 respectively).
- Extractor reads raw credentials from a Carrier.
- Authenticator validates an Authentication; AuthenticatorFunc
adapter; NamedAuthenticator capability for
OTel attribution.
- Manager first-success-wins chain; aggregates per-
authenticator errors via errors.Join when none
succeeds. Bug fixed at the design level: NEVER
iterates after a success.
- Engine high-level entry point: extract -> authenticate
-> store in context; returns Anonymous + a
typed error when no extractor is configured.
- WithAuthentication /
FromContext request-scope helpers with a private key.
- Voter / Decision /
Attribute authorization primitives (verdict layout -1/0/1).
- AccessDecisionManager Affirmative, Consensus (WithTieBreak), Unanimous
(WithAbstainFallback) strategies.
Extended errors.go: ErrNoExtractor, ErrAuthenticatorRefused,
ErrAccessDenied, ErrInsufficientScope (joining the Phase-0 sentinels).
OpenTelemetry: per the user's "spans direct in the core" decision,
every orchestration point opens its own span. No EventSink abstraction,
no otel/ module. tracerName constant + AttrXxx attribute keys centralised
in otel.go for diffability against docs/observability.md (Phase 11).
Spans emitted:
- security.Engine.Process (extractors.count, authenticated)
- security.Manager.Authenticate (authenticators.count, name,
per-authenticator events)
- security.AccessDecisionManager.Decide (strategy, attributes, decision)
Tests:
- Anonymous, context, manager, engine, ADM matrices (3 strategies x
grant/deny/abstain), Decision.String(), wrapped-error propagation,
race-safe scripted helpers, in-memory OTel exporter to assert span
names + attributes.
- Runnable Example_engine and ExampleNewManager (output-verified).
- `go test -race ./...` green; helpers use atomic counters to stay race-
clean under concurrent Manager use.
Legacy code deprecation:
- authentication.Filter, authentication.Provider, credential.Credential,
authorization.Option all carry `// Deprecated:` doc-blocks pointing to
the v2 replacements and the Phase 7 removal date.
- staticcheck SA1019 inside the legacy packages themselves is silenced
with `//nolint:staticcheck // legacy package, scheduled removal Phase 7`
so the lint job stays green while the deprecation message remains
visible to external users.
Mockery skipped this phase: the workspace .mockery.yaml is being
migrated to v3 syntax (pkgname/template/template-data) while the pinned
tool is still v2.53.5 -- documented in LIMITATIONS.md; Phase 4 will
reconcile it. CI explicitly skips `make generate` in the workflow.
Verification: `make sync && make build && make test && make lint` green
across the workspace (root + 10 sub-modules + example/oauth2).
…ity core
This is Phase 3 of the architecture refactor. The httpsec module
materialises the abstract Carrier/Extractor/Authenticator/Engine flow on
top of net/http with minimum boilerplate and zero router dependency.
New public surface (github.com/hyperscale-stack/security/http, package
httpsec):
- Carrier *http.Request + http.ResponseWriter -> security.Carrier
adapter. Lookup order: header > cookie > query
(deliberate: keeps URL-borne credentials from
leaking into access logs).
- Middleware(engine, opts...)
Top-level middleware. Runs the Engine on every
request; success enriches the context via
security.WithAuthentication, failure short-circuits
with the configured ErrorMapper. Anonymous flows
are denied by default (WithAnonymousFallback opts
in).
- Authorize(adm, attrs...)
Stand-alone authorisation middleware that consults
an AccessDecisionManager. Installable after
Middleware (or alone, with the request seen as
anonymous).
- AuthorizeWith(...) Same, with an explicit ErrorMapper override.
- ErrorMapper Interface; DefaultErrorMapper(scheme, realm)
produces RFC 7235-compliant responses and adds the
RFC 6750 §3.1 OAuth2 "error" / "error_description"
parameters for token-related failures.
- ExtractAuthorizationValue(scheme, header)
v2 replacement of the legacy
internal/header.ExtractAuthorizationValue.
- Options WithErrorMapper, WithRealm, WithChallengeScheme,
WithAnonymousFallback.
Behaviour:
- Bearer is the default challenge scheme.
- 401 maps ErrInvalidCredentials, ErrClientSecretMismatch,
ErrAuthenticatorRefused; ErrTokenExpired / ErrTokenNotFound add
error="invalid_token".
- 403 maps ErrAccessDenied; ErrInsufficientScope adds
error="insufficient_scope".
- 400 maps ErrUnsupportedCredential.
- Unknown errors fall through to 401 (safest default).
Observability:
- httpsec.Middleware opens its own span ("httpsec.Middleware") with
http.method / http.route / security.handled, then delegates to the
core's security.Engine.Process span (so a single request produces a
clean parent/child tree).
Tests:
- Carrier lookup order, multi-value reads, nil-safety.
- Middleware: success path, deny-by-default vs WithAnonymousFallback,
custom ErrorMapper, WWW-Authenticate realm + invalid_token parameter,
100-goroutine race test.
- Authorize: grant/deny/insufficient_scope, anonymous fallback.
- ExtractAuthorizationValue case-insensitivity table.
- Runnable ExampleMiddleware (output-verified) demonstrating the canonical
Bearer wiring.
- Benchmark: BenchmarkMiddleware ~812 ns/op, 22 allocs/op on M2 Ultra.
Verification: make sync && make build && make test && make lint green
across the full workspace.
This is Phase 4 of the architecture refactor. Three new modules ship; the legacy in-tree password/ package is moved aside so the new module can take its public import path. password module (github.com/hyperscale-stack/security/password): - New Hasher interface with context.Context plumbing and a typed NeedsRehash hook so applications can transparently upgrade stored hashes when operators raise the security baseline. - BCryptHasher (cost-clamped) on top of x/crypto/bcrypt. - Argon2idHasher (PHC string encoding) on top of x/crypto/argon2. - DefaultArgon2idParams() returns the OWASP 2024 / RFC 9106 §4 profile (memory=19 MiB, time=2, parallelism=1, key=32 B, salt=16 B). - Algorithm-aware error model: ErrMismatch (false-mismatch typed), ErrUnsupportedAlgorithm (cross-algorithm), ErrMalformedHash (storage corruption). - Verify uses constant-time comparison; Hash refuses to run a cancelled context. - Tests: round-trip, mismatch -> (false, nil), PHC parse errors, weak- parameter rehash, random-salt independence, OWASP defaults, race-safe with 50 / 32 concurrent calls, context cancellation. Legacy migration (in-tree only): - The old password package (BCrypt-only, no context) moves to internal/legacypassword/ so the legacy DAO provider keeps compiling until Phase 7 removes it. Imports in dao_authentication_provider*.go rewritten accordingly. The internal location signals to outside consumers that the API is closed. basic module (github.com/hyperscale-stack/security/basic): - PasswordUser interface (security.Principal + lifecycle predicates + GetPasswordHash). - UserLoader interface (LoadByUsername(ctx, username)). - basic.Authentication (immutable, "mutates" via WithAuthenticated; redacts the cleartext password on success). - Extractor for the HTTP Basic scheme (RFC 7617), case-insensitive scheme check, accepts ":" passwords, rejects invalid base64 / missing colon with security.ErrInvalidCredentials wrapped via basic.ErrBadFormat. - Authenticator built on UserLoader + password.Hasher, with optional AuthorityResolver. Every failure path (unknown user, lifecycle KO, hash error, password mismatch) collapses to security.ErrInvalidCredentials at the boundary to defeat account-enumeration; original causes stay in the error chain for ops via errors.As. - Implements security.NamedAuthenticator -> AuthenticatorName()="basic" for Manager span attribution. bearer module (github.com/hyperscale-stack/security/bearer): - TokenVerifier interface — the plug-in point for opaque, introspected, or JWT (Phase 6) verifiers. - bearer.Authentication (immutable), redacts the token on success. - Extractor for "Authorization: Bearer <token>" (RFC 6750 §2.1), case-insensitive scheme, ignores empty tokens (lets downstream extractors try). - QueryExtractor for "?access_token=<...>" (RFC 6750 §2.3). Marked Deprecated: in godoc with the RFC §5.3 list of pitfalls; opt-in only. - Authenticator panics on construction with a nil verifier (silently insecure config refused), delegates verification to TokenVerifier, wraps errors so security.ErrTokenExpired / ErrInvalidCredentials etc. reach the HTTP / gRPC error mappers untouched. - AuthenticatorName()="bearer". Workspace: - password/ added to go.work. - basic/ and bearer/ go.mod declare local replaces for security and (for basic) security/password to keep dev cycles fast. - make sync && make build && make test && make lint green across the whole workspace (root core + 11 sub-modules + example/oauth2). Tests recap: 30+ table-driven tests across the three modules, all with t.Parallel where the global tracer is not involved, race-safe helpers using atomic counters where mocks are shared across goroutines.
…alog
Phase 5 of the architecture refactor. The Voter / AccessDecisionManager
contract introduced in Phase 2 now has concrete content: a typed attribute
family in the core, a catalog of stock voters in the new voter/ sub-package
(part of the core module — no new go.mod), and composable And/Or/Not.
Concrete Attribute types (core):
- RoleAttribute — "ROLE_<name>" wire form, bare-name constructor.
- ScopeAttribute — "scope:<name>" wire form.
- AuthorityAttribute — verbatim wire form (no prefix convention).
- PermissionAttribute — carries an arbitrary predicate(ctx, auth) bool.
Voter catalog (github.com/hyperscale-stack/security/voter):
- HasRole / HasAnyRole — match against Authorities() with or
without the Spring-style ROLE_ prefix.
- HasScope / HasAnyScope — match OAuth2 scopes; accept three
storage conventions (bare, "scope:x",
"scope:a b c" space-packed).
- HasAuthority / HasAnyAuthority — verbatim match (no prefix).
- HasPermission — evaluates every PermissionAttribute's
predicate; nil predicate fails closed.
- Authenticated — granted iff IsAuthenticated().
- Anonymous — granted iff NOT IsAuthenticated()
(handy for signup, password-reset).
- FullyAuthenticated — same as Authenticated today; will refuse
remember-me sessions once Phase 10
ships the flag.
- And / Or / Not — composers with explicit truth tables.
All voters:
- Pure (no I/O), safe for concurrent use.
- Deny on unauthenticated input by default.
- Abstain when the attribute family is foreign.
Tests (table-driven where it matters):
- Per-voter Supports / Vote matrices, including storage-convention
variations for scopes and roles.
- Permission voter: nil-predicate fail-closed, multi-attribute "all must
grant" semantics, abstain when no permission attribute is supplied.
- Composite truth tables (And/Or/Not).
- Runnable Example + Example_compose (output-verified).
- Coverage on the voter package: 100% statements (manual inspection;
Makefile aggregate confirms).
Verification: make sync && make build && make test && make lint green
across the workspace (root core + 11 sub-modules + example/oauth2).
…adapter
Phase 6 of the architecture refactor. The jwt sub-module
(github.com/hyperscale-stack/security/jwt, package jwtsec) ships a
production-grade JOSE-based JWT toolkit usable both standalone and as a
bearer.TokenVerifier behind the security pipeline introduced in earlier
phases.
Surface (all in package jwtsec):
- Algorithm typed alias around the JOSE alg ids; defaults to the
asymmetric allowlist {RS256/384/512, PS256/384/512,
ES256/384/512, EdDSA}. HS256/384/512 are exported but
ONLY enabled when WithAllowedAlgorithms includes them
explicitly (defence-in-depth against the canonical
"RSA public key as HMAC secret" key-confusion
attack). "alg=none" is unconditionally rejected.
- PublicKey / PrivateKey
Minimal key descriptors wrapping crypto.PublicKey /
crypto.PrivateKey, plus KeyID and Algorithm.
- KeySet / JWKSProvider
KeySet.ByKeyID + Active() contract used by signer
and verifier. NewStaticJWKS for in-process keys.
- NewRemoteJWKS RFC 7517 fetcher with TTL cache, request body size
limit (1 MiB), graceful stale-fallback on upstream
hiccups, configurable http.Client / TTL.
- StandardClaims RFC 7519 + RFC 9068 §2.2.3 scope; nested types
Audience (string|[]string JSON shape) and
NumericDate (int|float JSON).
- validateStandardClaims iss / aud / exp / nbf / iat with configurable
clock skew, security.Clock injection.
- Signer interface + NewSigner compact JWS Sign with kid/alg headers,
OTel span jwtsec.Signer.Sign, panics on
empty Algorithm/Key (refuses silent
misconfig).
- Verifier interface + NewVerifier ParseSignedCompact with explicit
allowed-alg list (refuses tokens with
out-of-list alg BEFORE touching keys),
OTel span jwtsec.Verifier.Verify with
jwt.alg / jwt.kid / jwt.iss attributes
and per-failure span events.
- BearerVerifier adapter producing a bearer.TokenVerifier; default
AuthorityResolver materialises the OAuth2 "scope"
claim as "scope:<x>" authorities so the voter
package picks them up.
Errors:
- ErrInvalidSignature / ErrInvalidIssuer / ErrInvalidAudience /
ErrTokenExpired / ErrTokenNotYetValid / ErrAlgorithmNotAllowed /
ErrMalformedToken
- Each wraps the appropriate security.* sentinel (ErrInvalidCredentials
or ErrTokenExpired) so the HTTP / gRPC error mappers route them to the
right status without parsing message strings.
- AsAlgorithmName helper extracts the disallowed alg from
ErrAlgorithmNotAllowed for telemetry.
Options:
- WithAllowedAlgorithms (panics on empty list — the gateway to alg=none)
- WithIssuer / WithAudience (any-match) / WithClockSkew / WithClock
Tests (~17 cases, all t.Parallel):
- Round-trip across RS256, ES256, EdDSA.
- alg=none token rejected.
- Bad issuer / bad audience / matching audience.
- Expired vs near-miss within clock skew window.
- Unknown kid -> ErrInvalidSignature.
- Custom claims struct unmarshal.
- BearerVerifier default + custom AuthorityResolver, error propagation.
- Runnable Example demonstrating the canonical sign-then-verify flow.
Verification: make sync && make build && make test && make lint green
across the workspace (root core + 12 sub-modules + example/oauth2).
External dep added: github.com/go-jose/go-jose/v4 v4.1.4 (CNCF-maintained
JOSE primitives; per the Phase 6 arbitrage in the plan).
…ens, PKCE
Start of Phase 7 — the modular OAuth2 server. This first slice lands the
foundations: typed errors, client model, resource models (authorization
code, access token, refresh token, token pair), the Storage contract with
strict atomicity guarantees, an in-memory Storage implementation, opaque
token generators, and the PKCE helpers. Grants, client authentication
methods, endpoints and the Server orchestrator follow in 7b/7c/7d.
New surface
-----------
oauth2 module (github.com/hyperscale-stack/security/oauth2):
errors.go
RFC 6749 §5.2 error envelope (Error: Code/Description/URI/Cause +
HTTPStatus + WithDescription/WithCause), all standard codes plus
ErrCodeAlreadyUsed (auth-code reuse) and ErrRefreshTokenReused
(BCP §8.10.3 reuse detection). Each sentinel wraps the matching
security.* sentinel so the existing HTTP / gRPC error mappers route
them to the right status code without parsing strings.
client.go
Client interface (ID / Type / RedirectURIs / GrantTypes / Scopes /
AuthMethods), ClientType (confidential / public), SecretMatcher
optional capability (constant-time comparison), ClientStore for
loading clients by ID, DefaultClient in-memory implementation.
models.go
AuthorizationCode (raw + hash + PKCE fields + nonce + iat/exp),
AccessToken (raw + hash + family ID + scope + iat/exp + aud),
RefreshToken (raw + hash + family ID + Consumed flag + iat/exp),
TokenPair (couples access + optional refresh). All carry an IsExpired
predicate for testability.
storage.go
Per-aspect interfaces (AuthorizationCodeStore, AccessTokenStore,
RefreshTokenStore) composed into Storage. Contracts make the atomicity
guarantees explicit:
- ConsumeAuthorizationCode MUST atomically read+delete; reuse fails
with ErrCodeAlreadyUsed.
- RotateRefreshToken MUST atomically consume oldHash + persist next.
Reuse triggers ErrRefreshTokenReused AND family revocation.
- RevokeRefreshFamily marks every sibling consumed and revokes every
access token whose FamilyID matches.
hash.go
HashToken(pepper, token) = HMAC-SHA256(pepper, token) -> hex. The
pepper is a server-wide secret (32+ random bytes) so a leaked storage
table cannot validate guessed tokens offline.
oauth2/storage/memory (sub-module):
Sync.Mutex-guarded implementation of Storage. RotateRefreshToken
detects reuse and revokes the whole family before returning. Tests
exercise the atomic-rotation path.
oauth2/token (sub-package):
AccessTokenClaims + AccessTokenGenerator / RefreshTokenGenerator /
AuthorizationCodeGenerator interfaces, Opaque generator producing
base64url-encoded random bytes (min 16 / default 32) hashed with the
shared pepper. The hash MUST equal oauth2.HashToken so the generator
and the storage can find each other; the test locks that contract.
oauth2/pkce (sub-package):
RFC 7636 helpers: Method (S256, plain), Verify, VerifyS256, Challenge.
Constant-time comparison everywhere. Test vectors from RFC 7636
Appendix B.
Verification
------------
- make sync && make build && make test && make lint green across the
whole workspace (root + 13 sub-modules).
- New tests:
- oauth2/token: random + hash parity + size clamp + ctx cancel.
- oauth2/pkce: RFC 7636 vector, plain match, unknown method, round-
trip Challenge/Verify.
Workspace
---------
- go.work now lists ./oauth2/storage/memory as a sub-module so the
in-memory store builds with its own replace lines.
- oauth2/go.mod gains the OTel and testify deps (the OTel adapter will
surface in 7d for endpoint spans).
Phase 7b. Two thin pieces wiring the OAuth2 server to the JWT module without creating a hard dependency from oauth2 to jwt: oauth2/token/jwt.go - AccessTokenSigner interface — the contract a signer must satisfy to plug as access-token generator. Mirrors jwtsec.Signer.Sign while staying expressed in the oauth2 module's own types so the oauth2 module never imports jwt. - JWTAccessTokenGenerator wraps an AccessTokenSigner + pepper into an AccessTokenGenerator. The wire-form token is the JWS string; the storage hash is HMAC-SHA256(pepper, token) so revocation / introspection still find the record without persisting the raw token (RFC 9068 deployments often need to revoke access tokens server-side). jwt/oauth2_adapter.go - OAuth2AccessTokenSigner implements oauth2/token.AccessTokenSigner via an injected jwtsec.Signer. It projects token.AccessTokenClaims onto an RFC 9068 payload (StandardClaims + "client_id" extension). Wiring stays caller-controlled (composition root), keeping the oauth2 module dep graph free of JOSE: oauth2/token has no JWT dep jwt/oauth2_adapter.go opts-in to oauth2/token Tests: - oauth2_adapter_test.go: end-to-end sign-then-verify with claim projection assertions on iss / sub / aud / scope / client_id. - Constructor panic on nil signer is covered. Verification: make sync && make build && make test && make lint green across the workspace.
Phase 7c adds the three core grants and three client-authentication
methods to the modular OAuth2 server. The Server orchestrator and
endpoints land in 7d; this slice is internally testable via the existing
in-memory storage.
oauth2/grant
------------
- Grant interface (Type + Handle), Request envelope (Client + Form +
Issuer + Audience + Now — Now passed explicitly so tests stay
deterministic), Response (TokenPair + Scope + TokenType +
ExtraParams), Config bundle (Storage / generators / TTLs / RequirePKCE
/ RotateRefreshTokens).
- AuthorizationCode (RFC 6749 §4.1.3 + RFC 7636 PKCE)
* Atomic ConsumeAuthorizationCode -> single-use enforcement.
* Rebinds client (code.ClientID == authenticated client).
* Rebinds redirect_uri.
* Verifies PKCE when present; refuses when RequirePKCE is true and
no challenge was stored.
* Issues access token (+ optional refresh token) with a fresh
FamilyID for rotation tracking.
- ClientCredentials (RFC 6749 §4.4)
* No refresh token ever (RFC 6749 §4.4.3).
* narrowScopes() refuses requested scopes outside the client's
allowed list; empty request defaults to first allowed scope.
- RefreshToken (RFC 6749 §6 + OAuth 2.0 BCP §8.10)
* Detects reuse on consumed tokens -> revokes the whole family AND
returns ErrRefreshTokenReused.
* Refuses scope broadening (narrowScopesForRefresh).
* When RotateRefreshTokens is true, atomically rotates via the
storage's RotateRefreshToken and propagates ErrRefreshTokenReused
when the storage detects a concurrent reuse.
Helpers:
- grantTypeAllowed(client, type) — empty client GrantTypes() means "any".
- newFamilyID() — 16 random bytes as base64url (22 chars).
oauth2/clientauth
-----------------
- ClientAuthenticator interface (Method + Match + Authenticate).
- NewBasic (RFC 6749 §2.3.1, "Basic base64(id:secret)" header).
- NewPost (RFC 6749 §2.3.1 form variant, only when no
Authorization header is set so Basic wins on ties).
- NewNone (OpenID Core §9 for public clients; rejects
confidential clients trying to use it).
- All three:
* decode credentials -> LoadClient -> verify the secret via
oauth2.SecretMatcher (constant-time inside DefaultClient).
* Refuse clients whose AuthMethods() list excludes the method.
* Collapse every failure to oauth2.ErrInvalidClient (the original
cause stays reachable via errors.As for telemetry but never
leaks to the client).
Tests
-----
- AuthorizationCode happy path (with PKCE S256).
- Reuse detection on consumed code -> ErrCodeAlreadyUsed.
- PKCE verifier mismatch -> ErrInvalidGrant.
- redirect_uri mismatch -> ErrInvalidGrant.
- RequirePKCE forces ErrInvalidGrant when the stored code has no
challenge.
- ClientCredentials happy path; refresh token MUST NOT be issued.
- ClientCredentials scope broadening -> ErrInvalidScope.
- RefreshToken happy path + rotation; replaying the rotated old
token triggers ErrRefreshTokenReused (and the family-revocation
side-effect is exercised inside the memory storage).
Verification: make sync && make build && make test && make lint green
across the workspace.
Phase 7d wires the Phase 7c grants and clientauth methods together into a
mountable OAuth2 server with four RFC endpoints (token, revoke,
introspect, metadata). The /authorize endpoint is deferred to a follow-up
slice — it needs a consent flow and is the most opinionated piece of the
server; the token endpoint already exercises client_credentials and
refresh_token end-to-end, which covers the M2M use cases most teams
need at v0.
New surface (oauth2)
--------------------
Profile (profile.go)
Profile20 / Profile20BCP / Profile21Draft. The zero value is BCP
(recommended default). Profile predicates: AllowsLegacyGrant,
RequiresPKCE, RequiresRefreshRotation, AllowsPKCEPlain.
IssuerResolver (issuer.go)
Pluggable issuer/audience resolution per request. StaticIssuer for
single-tenant; multi-tenant deployments implement their own resolver
that dispatches on Host or routing prefix.
Server (server.go)
ServerConfig bundles Storage + ClientStore + IssuerResolver + Grants
+ ClientAuth + Now + Profile. NewServer:
- validates required fields
- builds O(1) grant dispatch map
- enforces profile constraints (password / implicit refused
outside Profile20) before exposing any handler.
ClientAuthenticator and Grant interfaces live in the parent package
so grant/* and clientauth/* implementations can satisfy them without
creating an import cycle.
GrantRequest / GrantResponse / Grant (grant_contract.go)
Moved here from oauth2/grant so the Server can reference them. The
grant sub-package re-exports them as type aliases for ergonomics.
Endpoints
TokenHandler — POST /token. ParseForm -> authenticateClient ->
dispatch[grant_type] -> writeTokenResponse.
invalid_client adds WWW-Authenticate Basic.
unsupported_grant_type returns 400 with the
oauth2 code.
MetadataHandler — RFC 8414 .well-known/oauth-authorization-server.
Issuer + endpoints + grant_types_supported +
token_endpoint_auth_methods_supported +
code_challenge_methods_supported. PKCE methods
derived from the active profile.
RevokeHandler — RFC 7009. Always 200; lookup hits access then
refresh; revoking a refresh token revokes the
whole family.
IntrospectHandler — RFC 7662. Active true on live access tokens AND
on live un-consumed refresh tokens (token_type
is "Bearer" / "refresh_token" respectively).
writeOAuthError emits the RFC 6749 §5.2 JSON envelope, sets
Cache-Control: no-store, and adds WWW-Authenticate Basic for
invalid_client. Non-OAuth errors collapse to server_error so the
wire stays compliant.
Tests (token_endpoint_test.go)
- client_credentials happy path: 200, JSON envelope, no refresh token,
Cache-Control: no-store, scope echoed.
- Missing grant_type -> 400 invalid_request.
- Bad client secret -> 401 with WWW-Authenticate: Basic.
- Unsupported grant_type -> 400 unsupported_grant_type.
- MetadataHandler advertises configured grants + auth methods.
- Server boot refuses to register the password grant under Profile20BCP.
Verification
------------
make sync && make build && make test && make lint green across the
workspace.
Deferred to a follow-up slice
-----------------------------
- /authorize endpoint (consent flow).
- private_key_jwt client auth method.
- JWKS endpoint (depends on jwt module + Server-side public key store).
Phase 7e closes the migration: every legacy package is deleted, the demo
and the end-to-end tests are ported to the v2 stack, and the core module
is trimmed down to its intended dependency set.
Removed (legacy v0 — superseded by Phases 2-7d)
-----------------------------------------------
- authentication/ (Filter, Provider, Handler, FilterHandler,
BearerFilter, AccessTokenFilter,
HTTPBasicFilter)
- authentication/credential/ (Credential, TokenCredential,
UsernamePasswordCredential, context)
- authentication/provider/dao/ (DaoAuthenticationProvider, UserProvider)
- authentication/provider/oauth2/ (the whole legacy provider + InMemory
storage + random token generator)
- authorization/ (Option, HasRole, AuthorizeHandler)
- internal/legacypassword/ (the BCrypt-only v0 hasher)
- internal/header/ (orphaned once the legacy filters went)
- user/ (the v0 User interface — the v2 stack
uses security.Principal)
The root module now exposes exactly two packages: the core (`.`) and
`voter`. Its direct dependency set is stdlib + go.opentelemetry.io/otel
(+ stretchr/testify scoped to tests) — gilcrest/alice, rs/zerolog,
hyperscale-stack/secure and golang.org/x/crypto are gone.
Ported to the v2 stack
----------------------
- example/oauth2: rebuilt as a single-binary demo running the modular
OAuth2 Server (token / revoke / introspect / metadata endpoints) AND a
Bearer-protected resource that validates opaque tokens against the
shared storage via an in-process introspection verifier. README probes
updated. Its go.mod drops alice/zerolog/secure and now requires
security + bearer + http + oauth2 + oauth2/storage/memory.
- internal/integrations: promoted to its own workspace module so it can
import the transport / oauth2 sub-modules. The legacy
oauth2_auth_by_{client,access_token} tests are replaced by:
* oauth2_token_test.go — client_credentials happy path + bad
secret + unknown client + no-auth-header,
all via the real /token endpoint.
* resource_server_test.go — full chain: mint a token via /token,
then call an httpsec.Middleware-protected
resource whose bearer.TokenVerifier
introspects the shared storage. Covers
valid token, bad token, missing token.
Docs
----
- LIMITATIONS.md rewritten: the Phase 0-7 limitations are resolved and
removed; the remaining gaps (/authorize endpoint, private_key_jwt,
JWKS endpoint, production stores, gRPC, sessions, examples/docs,
mockery tooling) are listed against their target phase.
- MIGRATION.md module table refreshed (statuses, new sub-modules,
legacy removal note); dependency-policy block corrected.
Verification
------------
make sync && make build && make test && make lint green across the
workspace (root core + voter + 13 sub-modules + example/oauth2 +
internal/integrations). No git push.
Phase 8a introduces a black-box conformance suite that every
oauth2.Storage implementation must pass. Running the same suite against
every backend (memory now, SQL and Redis in 8b/8c) catches behavioral
drift between stores at test time.
oauth2/storetest (new sub-package of the oauth2 module)
-------------------------------------------------------
RunConformance(t, factory) executes 11 sub-tests covering the full
Storage contract:
- authorization codes: save/consume round-trip, single-use
enforcement, unknown-code rejection, and a 50-goroutine race that
asserts exactly one consume wins (atomicity).
- access tokens: save/lookup/revoke lifecycle, unknown-token
rejection.
- refresh tokens: save/lookup, rotation (old marked consumed, new
live), reuse detection (replaying a consumed token fails with
ErrRefreshTokenReused and revokes the family), a 30-goroutine race
that asserts exactly one rotation wins, and family revocation
(sibling refresh tokens consumed + access tokens of the family
purged).
The package imports "testing" on purpose — it is a test helper in the
spirit of net/http/httptest and testing/fstest, called from the _test.go
files of each store.
oauth2/storage/memory
---------------------
TestMemoryStoreConformance runs RunConformance against memory.New().
The in-memory store passes every case, including the two concurrency
races, validating both the suite and the store.
Verification: make build && make test && make lint green across the
workspace.
Phase 8b ships sqlstore — a database/sql implementation of oauth2.Storage
that passes the full storetest conformance suite, including the
concurrency races. It supports PostgreSQL, MySQL and SQLite.
oauth2/store/sql (module)
-------------------------
- Dialect abstraction: queries are written with "?" placeholders and
rebound per dialect (Postgres -> $1,$2,…; MySQL / SQLite keep ?).
Exported dialects: Postgres, MySQL, SQLite.
- Schema(dialect) returns idempotent CREATE TABLE IF NOT EXISTS DDL for
three tables (oauth2_auth_codes / _access_tokens / _refresh_tokens)
plus family-id indexes. Timestamps are stored as BIGINT Unix seconds
to dodge the TIMESTAMP/DATETIME portability minefield; raw token/code
values are NEVER persisted — only their hashes.
- Store.Migrate applies the schema (handy for tests / small setups;
production runs it through a migration tool).
- Atomicity without SELECT…FOR UPDATE:
* ConsumeAuthorizationCode runs SELECT + DELETE in one transaction;
the DELETE's RowsAffected()==1 check picks the single winner when
callers race — concurrent losers get ErrCodeAlreadyUsed.
* RotateRefreshToken runs in one transaction: UPDATE … SET consumed=1
WHERE consumed=0; RowsAffected()==0 means the token was reused, so
the family is revoked and ErrRefreshTokenReused is returned.
* RevokeRefreshFamily consumes every sibling refresh token and purges
every access token of the family.
Tests
-----
- TestSQLiteStoreConformance runs the shared storetest.RunConformance
against a pure-Go SQLite backend (modernc.org/sqlite — no cgo, no
Docker), exercising the same 11-case suite the memory store passes,
concurrency races included.
- Migrate idempotency, constructor argument validation, dialect-name
stability.
Decoupling fix
--------------
- oauth2/storage/memory is no longer a separate Go module — it has zero
external dependencies, so being a module only created a module-graph
cycle (oauth2's tests import memory, memory imports oauth2). It is now
a plain sub-package of the oauth2 module. The SQL/Redis stores stay
separate modules because they pull heavy drivers.
- The Server end-to-end tests (token_endpoint_test.go) moved out of the
oauth2 module into internal/integrations, where importing memory is
legitimate. New oauth2_endpoints_test.go covers missing/unsupported
grant_type, GET rejection, metadata advertisement, revoke always-200,
introspect inactive-on-unknown, and the BCP-profile legacy-grant boot
refusal.
- The authorization-code expiry check was removed from the memory store:
stores now only guarantee atomic single-use read+delete; the grant
handler is the single place that validates IsExpired (with its
injected clock). This aligns the memory and SQL stores and fixes a
test that broke once the wall clock crossed a hard-coded expiry.
Verification: make sync && make build && make test && make lint green
across the workspace.
Phase 8c ships redisstore — a Redis implementation of oauth2.Storage that
passes the full storetest conformance suite, concurrency races included.
It completes Phase 8: memory / SQL / Redis now share one behavioural
contract.
oauth2/store/redis (module)
---------------------------
- Built on github.com/redis/go-redis/v9; New accepts any
redis.UniversalClient (single node, cluster, sentinel).
- Key layout under a configurable prefix (default "oauth2:"):
code:<hash> / at:<hash> / rt:<hash> — JSON values, EXPIRE-d to the
token lifetime; famrt:<id> / famat:<id> — sets tracking a family's
refresh / access token hashes for revocation.
- Raw token / code values are never stored — only hashes are keys, and
the JSON payload omits the secret entirely (codec.go DTOs).
- Atomicity via Lua scripts (a Redis script runs to completion with no
command interleaving):
* consumeCodeScript: GET + DEL — atomic single-use of an
authorization code.
* rotateRefreshScript: GET old, reject if cjson-decoded `consumed`
is true, else flip consumed (preserving PTTL), SET the new token,
SADD it to the family set. Reuse returns 'reused', which the Go
layer turns into ErrRefreshTokenReused + family revocation.
- RevokeRefreshFamily marks every sibling refresh token consumed
(TTL-preserving SET with KeepTTL) and deletes every access token of
the family.
- WithKeyPrefix namespaces keys so multiple tenants can share one Redis.
Tests
-----
- TestRedisStoreConformance runs storetest.RunConformance against a
miniredis-backed store (pure-Go Redis with an embedded Lua + cjson
interpreter — no Docker). The Lua scripts execute exactly as on a real
Redis, so the consume / rotate atomicity races are genuinely
exercised.
- Constructor nil-client guard; WithKeyPrefix tenant-isolation test
(two stores, identical hash, no cross-talk).
Verification: make sync && make build && make test && make lint green
across the workspace.
Phase 8 is complete: the shared storetest suite (Phase 8a) now runs
green against all three backends — in-memory, database/sql (SQLite,
plus Postgres/MySQL dialects), and Redis. testcontainers-based runs
against real Postgres/MySQL/Redis remain a nightly-CI follow-up
(tracked in LIMITATIONS.md).
Phase 9 ports the security core onto gRPC. The same Engine / Manager /
Voter / AccessDecisionManager primitives that drive the HTTP middleware
now drive gRPC server interceptors — proof that the Phase 2 core is
genuinely transport-agnostic.
New module (github.com/hyperscale-stack/security/grpc, package grpcsec)
----------------------------------------------------------------------
- Carrier adapts gRPC request metadata to security.Carrier. Reads
consult metadata.FromIncomingContext (keys lower-cased to match gRPC
normalisation); writes stage a response metadata.MD the interceptor
can flush via grpc.SetHeader.
- UnaryServerInterceptor / StreamServerInterceptor run the Engine on
every RPC. Success enriches the handler context via
security.WithAuthentication; failure short-circuits with a gRPC
status error from the ErrorMapper. The stream interceptor wraps
grpc.ServerStream so Context() exposes the enriched context.
Deny-by-default; WithAnonymousFallback opts into anonymous flows.
- UnaryAuthorize / StreamAuthorize enforce an AccessDecisionManager
against the request's Authentication. Installed after the
authentication interceptor in a grpc.ChainUnaryInterceptor.
- ErrorMapper + DefaultErrorMapper map security sentinels to gRPC codes:
* ErrUnsupportedCredential -> codes.InvalidArgument
* ErrAccessDenied / InsufficientScope -> codes.PermissionDenied
* ErrInvalidCredentials / ErrClientSecretMismatch / ErrTokenExpired /
ErrTokenNotFound / ErrAuthenticatorRefused / unclassified
-> codes.Unauthenticated
- Options: WithErrorMapper, WithAnonymousFallback.
Observability
-------------
Each interceptor opens its own span ("grpcsec.Authenticate" /
"grpcsec.Authorize") with rpc.method / security.authenticated
attributes. It deliberately does NOT open an "rpc" span — that belongs
to otelgrpc, which users compose alongside this interceptor.
Tests
-----
- bufconn-backed in-memory gRPC server using the standard
grpc_health_v1 service (Check = unary, Watch = server stream) as the
guinea-pig — no protobuf generation needed.
- Unary: authenticated call OK, missing credential -> Unauthenticated,
bad token -> Unauthenticated, anonymous fallback lets the call
through, custom ErrorMapper invoked, 50-goroutine race.
- Stream: authenticated Watch streams updates, missing credential
surfaces Unauthenticated on Recv.
- Authorize: role granted / denied / anonymous-denied (unary), scope
granted / denied (stream) via chained interceptors.
- DefaultErrorMapper classification table (incl. wrapped errors).
- Runnable Example. grpcsec coverage: 93.2%.
Verification: make sync && make build && make test && make lint green
across the workspace.
Phase 10 ships the session module: stateless, cookie-backed sessions for
browser apps. The whole session is sealed into the cookie — there is no
server-side store to provision — so it slots straight behind the httpsec
middleware via the security.Carrier abstraction.
New module (github.com/hyperscale-stack/security/session)
---------------------------------------------------------
- Session: ID, Values map, CSRFToken, CreatedAt / LastAccessed /
ExpiresAt. IsExpired + IdleExpired predicates.
- Codec: AES-256-GCM seal/open. GCM is an AEAD construction, so one pass
gives BOTH confidentiality and integrity — no separate HMAC. Key
rotation: keys[0] is the active encrypt key, every key is tried on
decrypt, so an operator prepends a new key and still reads cookies
sealed by the previous one. Each input key is SHA-256'd to a valid
32-byte AES key. All decode failures collapse to ErrDecode (no
padding-oracle-style leak).
- Manager: the cookie life cycle over a security.Carrier (no httpsec
import needed):
* Login — mint a session for a principal, write the cookie.
* Get — read + decrypt + validate (absolute + idle expiry).
* Touch — re-write with refreshed LastAccessed (sliding idle).
* Rotate — new session ID, same Values — the anti-session-fixation
move to call right after a privilege change.
* Logout — write an immediately-expired deletion cookie.
Cookie defaults are conservative: Secure, HttpOnly, SameSite=Lax;
every attribute is overridable (WithSecure(false) for local HTTP dev,
WithSameSite, WithTTL, WithIdleTimeout, WithCookieName, WithClock…).
- Extractor + Authenticator + PrincipalLoader: the session plugs into
the core Engine. The Extractor decodes the cookie into a pending
Authentication; the Authenticator resolves the live principal through
an application-supplied PrincipalLoader. NewAuthenticator panics on a
nil loader (a session authenticator with nothing to resolve would
silently authenticate every cookie).
- CSRF: synchronizer-token pattern. The per-session token lives inside
the encrypted, HttpOnly cookie (never JS-readable); CSRFToken /
VerifyCSRF (constant-time) let handlers check the value the client
echoed back.
Observability: Manager.Login / Get / Touch / Rotate / Logout each open a
span carrying a non-reversible session.id_hash (the raw ID is a
credential and never reaches a trace backend).
Tests (~30 cases)
-----------------
- Codec: round-trip, randomised ciphertext, tampered-value rejection,
garbage rejection, key rotation (old key kept = still decodes; dropped
= ErrDecode), empty-key-list guard.
- Manager: Login/Get round-trip, cookie security attributes
(HttpOnly/Secure/SameSite), no-cookie, Logout clears, Rotate changes
ID + keeps Values + keeps CreatedAt, absolute + idle expiry (fixed
clock), tampered-cookie rejection, WithSecure(false) dev mode,
50-goroutine race.
- Authenticator: full Engine round-trip, anonymous on no cookie, loader
error propagation, nil-principal rejection, foreign-auth rejection,
nil-loader panic.
- CSRF: token/verify, nil-safety, survives the cookie round-trip,
changes on Rotate.
- Runnable Example. session coverage: 82%.
Dependencies: stdlib crypto only (crypto/aes, crypto/cipher) — no
golang.org/x/crypto needed. The server-side session store (Redis / SQL)
remains a documented follow-up.
Verification: make sync && make build && make test && make lint green
across the workspace.
Adds the docs/ set (architecture, observability span catalog, security-considerations, migration-from-v0), a refreshed README with the module table and HTTP Basic quick start, and a CHANGELOG covering the v0 -> v1 rewrite. LIMITATIONS.md and MIGRATION.md are refreshed to the post-refactor state.
Adds four runnable, E2E-tested examples under examples/ — basic-http (HTTP Basic + role authorization), bearer-jwt (JWT issuance + scope gating), grpc-bearer (gRPC interceptors), and session-web (cookie login with CSRF). Each ships an httptest/bufconn end-to-end test. Adds .github/workflows/release.yml: a tag-driven multi-module release that validates the whole workspace before publishing a GitHub release.
The oauth2 root package and oauth2/clientauth had no own test files — their behaviour was only exercised transitively from internal/integrations, so per-package coverage read 0%. Adds unit tests for the error envelope, profiles, models, hashing, client records, the issuer resolver, the server constructor, and the four RFC endpoints, plus full coverage of the three client-authentication methods. oauth2: 0% -> 90.7%, oauth2/clientauth: 0% -> 100%.
attribute.go had no direct test — Role/Scope/Authority/Permission and their String()/Name() accessors read 0%. Adds a focused test file. core: 92.3% -> 98.2%.
Adds branch tests for the three grants — grant-type / scope / PKCE / client mismatches, the no-refresh-generator and no-rotation paths, constructor panics — and for the JWT access-token generator (signer error, context cancellation, storage-hash computation). oauth2/grant: 62.2% -> 89.7%, oauth2/token: 61.3% -> 96.8%.
The remote JWKS provider (fetch / cache / TTL / stale fallback) was wholly untested. Adds httptest-backed coverage for NewRemoteJWKS, the cache hit path, key-use filtering, the error paths, and the stale-cache fallback, plus tests for the signer accessors, Algorithm.String, KeySet.Active, the WithAllowedAlgorithms guard, the Audience / NumericDate JSON codecs, and the algorithm-disallowed error helper. jwt: 63.2% -> 87.0%.
The conformance suite exercised the happy paths; the backend-failure and decode-error branches stayed uncovered. Adds tests driving an unmigrated / closed database (SQL) and a closed miniredis / corrupt-payload backend (Redis), plus the Postgres placeholder-rebind dialect. oauth2/store/sql: 69.3% -> 91.2%, oauth2/store/redis: 73.8% -> 89.7%.
Adds a self-test that runs the shared storetest.RunConformance suite against the in-memory store, and refactors example/oauth2 to extract a testable buildServer() with an end-to-end test (token issuance, protected resource, metadata, deny-by-default). oauth2/storetest: 0% -> 81.2%, example/oauth2: 0% -> 71.4%.
Adds focused tests for the immutable Authentication value types (basic / bearer / session), the gRPC metadata carrier writes, the HTTP carrier WithContext + WithChallengeScheme option, the PKCE Method stringer, the session cookie options + Touch, and the permission voter's Supports. These small accessors were never exercised directly. basic 90.6%->98.4%, bearer 81.4%->98.3%, grpc 93.2%->100%, http 87.0%->92.0%, pkce 92.9%->100%, session 82.0%->91.0%, voter 95.4%->97.2%.
The repository carried two demo trees: the pre-Phase-11 example/oauth2 module and the Phase-11 examples/ module. They are consolidated — the OAuth2 demo moves to examples/oauth2 as a sub-package of the examples module, and the standalone example/oauth2 go.mod is removed. The coverage aggregation in the Makefile now drops example program lines: their main() binds a socket and blocks, so it is not unit-testable and skewed the library figure. Examples are still built, tested, and linted. Aggregate library coverage: 92.1%.
Sweep of leftovers from the phased rewrite:
- delete TODO.md (v0 filter/provider planning notes) and
ARCHITECTURE_REPORT.md (stale v0 report, superseded by docs/).
- drop the "Real implementation lands in Phase N" placeholder trailers
and stale "(Phase N)" parentheticals from every doc.go and from the
anonymous / server / memory / verifier / auth_state comments.
- remove dead code: the `var _ = errors.Is` import-keeper hack in
jwt/signer.go, and basic.ErrUserNotFound / errUserNotFound (an unused
sentinel pair nothing referenced).
- wire the jwt verifier to return errAlgorithmDisallowed on a disallowed
alg, so the previously unreachable AsAlgorithmName helper works.
- MIGRATION.md: drop the obsolete Phase-1 history sections; fix the
oauth2 doc ("new modular" / legacy-package reference) and the jwt doc
(issuer/audience are opt-in, not on by default).
- LIMITATIONS.md: the session module ships no `session.Store` interface
(stateless cookie only) — reword accordingly.
- go mod tidy: http/go.mod was missing its direct dependency on the core.
NewQueryExtractor / QueryExtractor read the bearer token from a "?access_token=" URL parameter (RFC 6750 §2.3). Query-borne tokens leak into access logs, browser history, and Referer headers; the API was already marked Deprecated and no consumer uses it. Removed along with its tests and the doc.go mention — the bearer module now offers only the Authorization-header scheme (§2.1).
The token / revoke / introspect / authorize endpoints were hardcoded as "/oauth2/<name>" in the RFC 8414 discovery document, so mounting the handlers under any other path made the metadata advertise wrong URLs. ServerConfig now carries a RoutePrefix field (default "/oauth2", leading slash added and trailing slash trimmed, "/" meaning a root mount). The metadata document builds the endpoint URLs from issuer + RoutePrefix; the jwks_uri keeps its host-root .well-known location per RFC 8615. The dangling [Server.Metadata] reference in the MetadataHandler godoc — which pointed at a method that never existed — is replaced.
GrantRequest now carries the server's Profile, and the token endpoint fills it. The authorization_code grant enforces it: PKCE is required when the profile mandates it (BCP / 2.1) even if the grant's own RequirePKCE flag is off, and the "plain" PKCE transformation is refused unless the profile tolerates it (Profile20 only). A profile can only tighten a grant's configuration, never relax it.
Adds Server.AuthorizeHandler — the RFC 6749 §3.1 authorization endpoint for the authorization_code flow. The library owns the protocol plumbing (request validation, code minting, redirect); the application owns the login / consent UI through a ConsentFunc hook. The handler validates client, redirect URI (exact-match), response type, scope, and PKCE (profile-driven). Errors before the redirect URI is trusted return 400 without redirecting (open-redirector protection); later errors are redirected as RFC 6749 §4.1.2.1 error responses. The consent step may narrow the granted scope but never broaden it. Authorization codes are stored pepper-free so the authorization_code grant consumes them with the matching hash. oauth2: 91.8% coverage.
Adds grant.NewLegacyPassword + the ResourceOwnerVerifier hook. The grant is opt-in (registered explicitly in ServerConfig.Grants) and NewServer refuses it outside Profile20 — the OAuth 2.0 Security BCP and OAuth 2.1 drop the password grant because it makes the client handle the user's password. The godoc flags it LEGACY / discouraged. Token issuance is factored into a shared issueTokenPair helper now used by both the authorization_code and legacy password grants. oauth2 91.8%, oauth2/grant 91.0%.
AuthorizeConfig gains AllowImplicit + ImplicitTokens + ImplicitTTL. When enabled, /authorize serves the RFC 6749 §4.2 implicit flow: the access token is minted, stored, and returned in the redirect fragment; implicit errors travel in the fragment too (§4.2.2.1). LEGACY — discouraged: the implicit flow exposes the access token in the URL. AuthorizeHandler panics if AllowImplicit is set on a server whose profile is not Profile20, or without an ImplicitTokens generator. The new OpaqueTokenGenerator interface lets token.OpaqueRefreshAdapter feed it. oauth2: 90.4% coverage.
- LIMITATIONS.md: drop the resolved /authorize gap; record the known /introspect + /revoke pepper-free hashing inconsistency. - oauth2 doc.go, docs/architecture.md, CHANGELOG.md: describe the /authorize endpoint (code + opt-in implicit), runtime profile enforcement, the legacy password grant, and RoutePrefix. - examples/oauth2: wire the authorization-code flow — a consent hook, a /callback page, the authorization_code grant — with an end-to-end test driving consent -> code -> token exchange.
Token issuance peppered the storage hash (token.NewOpaque / NewJWTAccessTokenGenerator took a pepper) while every lookup path — the refresh_token grant, /introspect, /revoke — hashed with HashToken(nil, …). A non-nil pepper therefore made introspection, revocation, and the refresh grant silently fail to find any token they had issued. Opaque tokens carry >=128 bits of entropy, so a bare SHA-256 is already preimage- and brute-force-resistant — peppering bought nothing. The token generators now hash pepper-free, matching the lookup paths: every opaque token and code in the system is HashToken(nil, raw). token.NewOpaque(size) and token.NewJWTAccessTokenGenerator(signer) lose their pepper parameter. An integrations test mints a token over a grant and proves /introspect and /revoke now find it.
DefaultErrorMapper built the RFC 6750 error_description from errors.Unwrap(err).Error(), which exposed the whole wrapped error chain — server timestamps (now=/exp=), internal package and authenticator names, and any context a consumer's TokenVerifier wrapped around a core sentinel (token values, DSN/DB errors). classify now returns a fixed, generic description per RFC 6750 §3.1 error code; challenge no longer takes the error and never derives header content from it. This mirrors the gRPC mapper, which already emits fixed terse strings. Adds a regression test asserting a wrapped sensitive value never reaches the header.
The verifier accepted a validly-signed JWT with no `exp` claim — a token that never expires and, if leaked, stays valid forever. RFC 9068 §2.2 makes `exp` REQUIRED for JWT access tokens, and the project's doctrine is fail-closed by default. validateStandardClaims now rejects a missing `exp` with the new ErrMissingExpiry sentinel (wrapping security.ErrTokenExpired, so the HTTP/gRPC mappers classify it as invalid_token / Unauthenticated). WithOptionalExpiry opts out for general-purpose verification of deliberately non-expiring assertions. Test fixtures that signed exp-less tokens now set `exp`; adds coverage for the new default and the opt-out.
The in-memory user store repeats role string literals; in example code readability beats deduplication, so suppress goconst inline.
The default WWW-Authenticate scheme was a repeated string literal across error_mapper.go and middleware.go; hoist it to defaultChallengeScheme.
The RFC 7517 "use":"sig" literal was repeated across jwks.go and keyset.go; hoist it to keyUseSignature.
goconst: hoist repeated RFC string literals into constants — TokenTypeBearer (RFC 6750 token type, exported and reused by the grant package), responseTypeCode/responseTypeToken, the storetest fixtures, and reuse the existing pkce.Method constants for PKCE method names. gosec: annotate the three /authorize http.Redirect calls (G710 — the redirect target is exact-matched against the client's registered URIs) and the token-response encode (G117 — wire field names mandated by RFC 6749 §5.1) with justified nolint directives.
gosec G124 cannot prove the Secure/HttpOnly/SameSite attributes are safe because they are populated from the Manager configuration. The defaults are secure (Secure, HttpOnly, SameSiteLax); the framework intentionally lets callers tune them. Mark both http.Cookie literals with a justified nolint directive.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.