Skip to content

Feature/refactoring#32

Merged
euskadi31 merged 47 commits into
masterfrom
feature/refactoring
May 21, 2026
Merged

Feature/refactoring#32
euskadi31 merged 47 commits into
masterfrom
feature/refactoring

Conversation

@euskadi31
Copy link
Copy Markdown
Contributor

No description provided.

euskadi31 added 30 commits May 19, 2026 02:33
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).
euskadi31 added 8 commits May 21, 2026 00:26
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.
@euskadi31 euskadi31 self-assigned this May 21, 2026
Copilot AI review requested due to automatic review settings May 21, 2026 00:31
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

euskadi31 added 3 commits May 21, 2026 02:40
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.
@euskadi31 euskadi31 requested a review from Copilot May 21, 2026 01:22
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

euskadi31 added 6 commits May 21, 2026 09:49
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.
@euskadi31 euskadi31 merged commit 254b9db into master May 21, 2026
2 checks passed
@euskadi31 euskadi31 deleted the feature/refactoring branch May 21, 2026 09:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants