From 6ef4a07ec31464c6a9c21659514f89713e66b620 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Mon, 12 Oct 2026 23:54:18 +0200 Subject: [PATCH 01/29] fix(core): fail closed on contract violations and failed authentications After a failed authentication the Engine stored the extracted Authentication (a cleartext password, a bearer token) in the request context and returned it; the Manager returned it too. Engine.Process and Manager.Authenticate now return the anonymous authentication on every failure, and the Engine stores it in the context. Contract violations no longer panic the request path: an authenticator or a custom Manager reporting success without an authenticated value is a refusal (an error wrapping no security sentinel), a nil Attribute denies the decision, a nil Authentication is evaluated as anonymous and a voter returning an undefined Decision votes Deny. Nil dependencies are refused at construction (NewEngine, NewManager, the decision managers). The Manager treats an authenticator error wrapping ErrUnsupportedCredential, the documented runtime bail-out, as an abstention instead of joining it with the real refusals: joined, it let a wrong password be classified as an unsupported credential. New sentinels for the transports' RFC 6750 and RFC 9110 mapping: ErrAuthenticationRequired, ErrInvalidRequest and ErrUnavailable (a dependency is temporarily down: not a verdict on the credentials). Audit: CORE-I3, CORE-15, CORE-10. --- CHANGELOG.md | 24 +++ MIGRATION.md | 35 ++++ access_decision_manager.go | 93 +++++++-- authenticator.go | 21 +- contract_test.go | 401 +++++++++++++++++++++++++++++++++++++ engine.go | 66 +++++- engine_test.go | 6 +- errors.go | 59 +++++- errors_test.go | 7 + manager.go | 56 ++++-- manager_test.go | 2 +- 11 files changed, 706 insertions(+), 64 deletions(-) create mode 100644 contract_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b9698541..616eccd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -432,6 +432,13 @@ legacy packages (`authentication/`, `authorization/`, the in-tree no refresh token outlives the deadline and a later refresh is `invalid_grant`. `RefreshTTL` keeps its inactivity semantics (RFC 9700 §4.14.2). +- Core sentinels `security.ErrAuthenticationRequired` (no credential the + server could validate, or an unauthenticated principal refused by an + access decision), `security.ErrInvalidRequest` (a malformed authentication + request, such as a repeated `Authorization` field) and + `security.ErrUnavailable` (the credentials could not be checked because a + dependency — key set, user store, introspection endpoint — is temporarily + unavailable; not a verdict on the credentials). ### Changed @@ -598,6 +605,15 @@ legacy packages (`authentication/`, `authorization/`, the in-tree and SQL schemas)**: every store persists it (`store/sql`: a `family_issued_at BIGINT NOT NULL DEFAULT 0` column existing databases add with the `ALTER TABLE` of [MIGRATION.md](MIGRATION.md)). +- `Engine.Process` and `Manager.Authenticate` return `security.Anonymous()` + on every failure, and the Engine stores the anonymous authentication in + the returned context: the extracted credentials never flow back to the + caller. The `Manager` treats an authenticator error wrapping + `ErrUnsupportedCredential` as an abstention (the documented runtime + bail-out) rather than a refusal joined with the others, and a "success" + without an authenticated value as a refusal. `NewEngine`, `NewManager` + and the three `AccessDecisionManager` constructors panic on a nil + dependency. See [MIGRATION.md](MIGRATION.md). - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -710,6 +726,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `application/x-www-form-urlencoded` body and does not check the content type; client grant types are compared exactly, not case-insensitively; and `Profile20BCP` refuses the legacy `password` grant as well as `implicit`. +- Contract violations no longer panic the request path: an authenticator or + a custom `Manager` returning no authenticated value without an error, a + nil `Attribute`, a nil `Authentication` passed to `Decide`, and a voter + returning an undefined `Decision` now fail closed (an error, a denial, the + anonymous authentication, a deny vote). - The SAML Redis store answered `ErrTransactionExists` only when a second save differed from the stored transaction: an identical one succeeded. It @@ -1132,3 +1153,6 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `client_id=B` in its body was served as A. Both, and several `Authorization` fields, are now `invalid_request` (RFC 6749 §5.2) at `/token`, `/revoke` and `/introspect`. +- After a failed authentication the `Engine` stored the extracted + `Authentication` — a cleartext password, a bearer token — in the request + context; it now stores the anonymous authentication. diff --git a/MIGRATION.md b/MIGRATION.md index e4582be1..080bd8ed 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1260,3 +1260,38 @@ To migrate: key last on every instance; once all of them run with it, move it first; remove the old key once `TransactionTTL` (10 minutes by default) has elapsed. + +### core: failures return the anonymous authentication; contract violations fail closed + +After a failed authentication, `Engine.Process` stored the extracted +`Authentication` — a cleartext password, a bearer token — in the request +context and returned it, and `Manager.Authenticate` returned it too. A +component breaking its contract made the request path panic: an +authenticator returning `(nil, nil)`, a nil `Attribute`, a nil +`Authentication` passed to `Decide`. And an authenticator bailing out at +runtime with `ErrUnsupportedCredential` — the documented way to do so — had +its error joined with the other authenticators' refusals, so a wrong password +could be answered as an unsupported credential. + +| Situation | Before | After | +| --- | --- | --- | +| `Engine.Process` / `Manager.Authenticate` fail | return (and store) the extracted value | return (and store) `security.Anonymous()` | +| An authenticator returns `(nil, nil)` | panic | refusal; the next authenticator is tried | +| An authenticator returns an unauthenticated value and no error | success | refusal (contract violation) | +| A custom `Manager` returns no authenticated value and no error | panic / success | `Process` fails | +| An authenticator returns an error wrapping `ErrUnsupportedCredential` | joined into `ErrAuthenticatorRefused` | abstention; `ErrUnsupportedCredential` when every supporting authenticator bails out | +| `Decide` with a nil `Attribute` | panic | `ErrAccessDenied` | +| `Decide` with a nil `Authentication` | panic | evaluated as `security.Anonymous()` | +| A voter returns an undefined `Decision` | ignored (abstention) | counted as `DecisionDeny` | +| `NewEngine(nil, …)`, a nil extractor, authenticator or voter | panic on the first request | panic at construction | + +To migrate: + +1. Do not read the presented credentials from the value `Engine.Process` + returns on failure: it is `security.Anonymous()`. Per-username throttling + or lockout belongs in an `Authenticator` wrapper, which sees the + credentials before they are checked. +2. Make every `Authenticator` (and `bearer.TokenVerifier`) return an + authenticated value on success, and an error wrapping a security sentinel + otherwise. +3. Pass non-nil managers, extractors, authenticators, voters and attributes. diff --git a/access_decision_manager.go b/access_decision_manager.go index eb45a421..1d30aeb8 100644 --- a/access_decision_manager.go +++ b/access_decision_manager.go @@ -6,6 +6,7 @@ package security import ( "context" + "fmt" "strings" "go.opentelemetry.io/otel/codes" @@ -51,18 +52,19 @@ func WithAbstainFallback(d Decision) admOption { //nolint:revive // exported via } // NewAffirmativeDecisionManager returns an [AccessDecisionManager] that -// grants access as soon as one voter does, and denies otherwise. +// grants access as soon as one voter does, and denies otherwise. It panics +// if one of the voters is nil. func NewAffirmativeDecisionManager(voters ...Voter) AccessDecisionManager { return &accessDecisionManager{ strategy: "affirmative", - voters: cloneVoters(voters), + voters: cloneVoters("NewAffirmativeDecisionManager", voters), decide: affirmative, } } // NewConsensusDecisionManager returns an [AccessDecisionManager] that // follows majority rule. Pass [WithTieBreak] to override the default -// (deny-on-tie) behavior. +// (deny-on-tie) behavior. It panics if one of the voters is nil. func NewConsensusDecisionManager(voters []Voter, opts ...admOption) AccessDecisionManager { cfg := admConfig{tieBreak: DecisionDeny} for _, o := range opts { @@ -71,14 +73,15 @@ func NewConsensusDecisionManager(voters []Voter, opts ...admOption) AccessDecisi return &accessDecisionManager{ strategy: "consensus", - voters: cloneVoters(voters), + voters: cloneVoters("NewConsensusDecisionManager", voters), decide: consensus(cfg.tieBreak), } } // NewUnanimousDecisionManager returns an [AccessDecisionManager] that // refuses on a single deny and otherwise grants when at least one voter -// grants. Pass [WithAbstainFallback] to control the all-abstain case. +// grants. Pass [WithAbstainFallback] to control the all-abstain case. It +// panics if one of the voters is nil. func NewUnanimousDecisionManager(voters []Voter, opts ...admOption) AccessDecisionManager { cfg := admConfig{abstainFallback: DecisionDeny} for _, o := range opts { @@ -87,7 +90,7 @@ func NewUnanimousDecisionManager(voters []Voter, opts ...admOption) AccessDecisi return &accessDecisionManager{ strategy: "unanimous", - voters: cloneVoters(voters), + voters: cloneVoters("NewUnanimousDecisionManager", voters), decide: unanimous(cfg.abstainFallback), } } @@ -99,6 +102,11 @@ type accessDecisionManager struct { } // Decide implements [AccessDecisionManager]. +// +// Contract violations fail closed: a nil auth is treated as [Anonymous], a +// nil attribute denies the request before any voter runs, and a voter +// returning a value other than [DecisionGrant] or [DecisionAbstain] is +// counted as [DecisionDeny]. func (m *accessDecisionManager) Decide(ctx context.Context, auth Authentication, attrs []Attribute) error { ctx, span := tracer().Start(ctx, "security.AccessDecisionManager.Decide") defer span.End() @@ -108,6 +116,29 @@ func (m *accessDecisionManager) Decide(ctx context.Context, auth Authentication, AttrAttributes.String(joinAttributes(attrs)), ) + if auth == nil { + auth = Anonymous() + } + + final := DecisionDeny + if !containsNil(attrs) { + final = m.decide(m.votes(ctx, auth, attrs)) + } + + span.SetAttributes(AttrDecision.String(final.String())) + + if final == DecisionGrant { + return nil + } + + span.SetStatus(codes.Error, ErrAccessDenied.Error()) + + return ErrAccessDenied +} + +// votes collects one normalized vote per voter: an abstention for a voter +// that supports none of the attributes, the voter's verdict otherwise. +func (m *accessDecisionManager) votes(ctx context.Context, auth Authentication, attrs []Attribute) []Decision { votes := make([]Decision, 0, len(m.voters)) for _, v := range m.voters { @@ -117,19 +148,24 @@ func (m *accessDecisionManager) Decide(ctx context.Context, auth Authentication, continue } - votes = append(votes, v.Vote(ctx, auth, attrs)) + votes = append(votes, normalize(v.Vote(ctx, auth, attrs))) } - final := m.decide(votes) - span.SetAttributes(AttrDecision.String(final.String())) + return votes +} - if final == DecisionGrant { - return nil +// normalize maps every value but Grant and Abstain to Deny, so an undefined +// Decision can never be mistaken for an abstention (which a fallback could +// turn into a grant). +func normalize(d Decision) Decision { + switch d { + case DecisionGrant, DecisionAbstain: + return d + case DecisionDeny: + return DecisionDeny + default: + return DecisionDeny } - - span.SetStatus(codes.Error, ErrAccessDenied.Error()) - - return ErrAccessDenied } // affirmative returns Grant on first grant, Deny otherwise. Abstentions are @@ -209,6 +245,17 @@ func unanimous(allAbstainFallback Decision) func([]Decision) Decision { } } +// containsNil reports whether attrs holds a nil Attribute. +func containsNil(attrs []Attribute) bool { + for _, a := range attrs { + if a == nil { + return true + } + } + + return false +} + func anySupported(v Voter, attrs []Attribute) bool { for _, a := range attrs { if v.Supports(a) { @@ -226,13 +273,27 @@ func joinAttributes(attrs []Attribute) string { parts := make([]string, len(attrs)) for i, a := range attrs { + if a == nil { + parts[i] = "" + + continue + } + parts[i] = a.String() } return strings.Join(parts, ",") } -func cloneVoters(in []Voter) []Voter { +// cloneVoters copies the voter list, panicking on a nil voter: it would +// otherwise surface as a nil dereference on the first request. +func cloneVoters(constructor string, in []Voter) []Voter { + for i, v := range in { + if v == nil { + panic(fmt.Sprintf("security: %s: nil Voter at index %d", constructor, i)) + } + } + cp := make([]Voter, len(in)) copy(cp, in) diff --git a/authenticator.go b/authenticator.go index 78f4b95f..db3ca002 100644 --- a/authenticator.go +++ b/authenticator.go @@ -16,11 +16,22 @@ import "context" // type. Implementations MUST be cheap (a type switch); they MUST NOT // perform I/O. // - Authenticate validates the credential and either returns the new, -// authenticated value or an error wrapping a security sentinel -// ([ErrInvalidCredentials], [ErrTokenExpired], ...). Returning -// ([ErrUnsupportedCredential]) is the canonical way to bail out at -// runtime when Supports returned true but the value was nonetheless -// out of scope. +// authenticated value (IsAuthenticated() == true) or an error wrapping +// a security sentinel. +// +// The sentinel tells the transports how to answer: +// +// - a verdict on the credentials wraps [ErrInvalidCredentials], +// [ErrTokenExpired], [ErrTokenNotFound] or [ErrClientSecretMismatch]; +// - a dependency that is temporarily down (user store, key set, +// introspection endpoint) wraps [ErrUnavailable]: the credentials could +// not be checked, they are not refused; +// - [ErrUnsupportedCredential] is the canonical way to bail out at runtime +// when Supports returned true but the value was nonetheless out of +// scope: the [Manager] counts it as an abstention. +// +// An error wrapping no sentinel, or a "success" without an authenticated +// value, is a contract violation the transports answer as an internal error. // // Implementations MUST be safe for concurrent use. type Authenticator interface { diff --git a/contract_test.go b/contract_test.go new file mode 100644 index 00000000..1cadfa2a --- /dev/null +++ b/contract_test.go @@ -0,0 +1,401 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package security_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// authReadingVoter dereferences the Authentication it is given: it proves +// that Decide never hands a nil Authentication to a voter. +type authReadingVoter struct{} + +func (authReadingVoter) Supports(security.Attribute) bool { return true } + +func (authReadingVoter) Vote(_ context.Context, auth security.Authentication, _ []security.Attribute) security.Decision { + if auth.IsAuthenticated() { + return security.DecisionGrant + } + + return security.DecisionDeny +} + +// constDecisionVoter supports everything and returns a fixed, possibly +// undefined, Decision. +type constDecisionVoter struct{ d security.Decision } + +func (constDecisionVoter) Supports(security.Attribute) bool { return true } + +func (v constDecisionVoter) Vote(context.Context, security.Authentication, []security.Attribute) security.Decision { + return v.d +} + +// scriptedManager is a custom Manager returning a fixed result. +type scriptedManager struct { + result security.Authentication + err error +} + +func (m scriptedManager) Authenticate(context.Context, security.Authentication) (security.Authentication, error) { + return m.result, m.err +} + +// credentialSentinels are the sentinels that state a verdict on the +// credentials; a contract violation must wrap none of them. +var credentialSentinels = []error{ + security.ErrInvalidCredentials, + security.ErrClientSecretMismatch, + security.ErrTokenExpired, + security.ErrTokenNotFound, + security.ErrUnsupportedCredential, + security.ErrAuthenticationRequired, + security.ErrInvalidRequest, + security.ErrUnavailable, +} + +func assertNoCredentialSentinel(t *testing.T, err error) { + t.Helper() + + for _, s := range credentialSentinels { + assert.NotErrorIs(t, err, s, "a contract violation is not a verdict on the credentials") + } +} + +func TestNewSentinelsAreDistinctSecurityErrors(t *testing.T) { + t.Parallel() + + cases := []struct { + err error + want string + }{ + {security.ErrAuthenticationRequired, "security: authentication required"}, + {security.ErrInvalidRequest, "security: invalid authentication request"}, + {security.ErrUnavailable, "security: authentication temporarily unavailable"}, + } + + for _, c := range cases { + assert.Equal(t, c.want, c.err.Error()) + + _, ok := any(c.err).(security.SecurityError) + assert.True(t, ok, "%v must implement SecurityError", c.err) + + for _, other := range []error{ + security.ErrInvalidCredentials, security.ErrAccessDenied, security.ErrUnsupportedCredential, + security.ErrNoExtractor, security.ErrAuthenticatorRefused, + } { + assert.NotErrorIs(t, c.err, other) + } + } + + assert.NotErrorIs(t, security.ErrAuthenticationRequired, security.ErrInvalidRequest) + assert.NotErrorIs(t, security.ErrInvalidRequest, security.ErrUnavailable) + assert.NotErrorIs(t, security.ErrUnavailable, security.ErrAuthenticationRequired) +} + +// Audit probe TestZZAuditEngineNilResultPanics: an authenticator returning +// (nil, nil) — or an unauthenticated value without an error — breaks the +// Authenticator contract. The Engine must fail closed, never panic. +func TestEngineFailsClosedWhenAnAuthenticatorReturnsNoAuthenticatedValue(t *testing.T) { + t.Parallel() + + cases := map[string]security.Authentication{ + "nil result": nil, + "unauthenticated result": newFakeAuth("alice").withCredentials("pw"), + } + + for name, result := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + engine := security.NewEngine( + security.NewManager(security.AuthenticatorFunc( + func(context.Context, security.Authentication) (security.Authentication, error) { + return result, nil + })), + scriptedExtractor{auth: newFakeAuth("alice").withCredentials("pw")}, + ) + + var ( + ctx context.Context + auth security.Authentication + err error + ) + + require.NotPanics(t, func() { + ctx, auth, err = engine.Process(context.Background(), newMapCarrier()) + }) + + require.Error(t, err) + assert.ErrorIs(t, err, security.ErrAuthenticatorRefused) + assertNoCredentialSentinel(t, err) + assert.Equal(t, security.Anonymous(), auth) + + stored, ok := security.FromContext(ctx) + assert.True(t, ok) + assert.Equal(t, security.Anonymous(), stored) + }) + } +} + +func TestEngineFailsClosedWhenTheManagerReturnsNoAuthenticatedValue(t *testing.T) { + t.Parallel() + + cases := map[string]security.Authentication{ + "nil result": nil, + "unauthenticated result": newFakeAuth("alice").withCredentials("pw"), + } + + for name, result := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + engine := security.NewEngine( + scriptedManager{result: result}, + scriptedExtractor{auth: newFakeAuth("alice").withCredentials("pw")}, + ) + + var ( + ctx context.Context + auth security.Authentication + err error + ) + + require.NotPanics(t, func() { + ctx, auth, err = engine.Process(context.Background(), newMapCarrier()) + }) + + require.Error(t, err) + assertNoCredentialSentinel(t, err) + assert.Equal(t, security.Anonymous(), auth) + + stored, _ := security.FromContext(ctx) + assert.Equal(t, security.Anonymous(), stored) + }) + } +} + +// Audit probe TestZZAuditEngineFailureKeepsCredentialsInContext: on a +// refusal the Engine stored the extracted value — cleartext password +// included — in the context. +func TestEngineFailureLeavesNoCredentialsInContext(t *testing.T) { + t.Parallel() + + cases := map[string]security.Manager{ + "refused": security.NewManager(security.AuthenticatorFunc( + func(_ context.Context, a security.Authentication) (security.Authentication, error) { + return a, fmt.Errorf("load user: %w", security.ErrInvalidCredentials) + })), + "unsupported": security.NewManager(), + } + + for name, manager := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(manager, scriptedExtractor{auth: newFakeAuth("alice").withCredentials("S3cr3t!")}) + + ctx, got, err := engine.Process(context.Background(), newMapCarrier()) + + require.Error(t, err) + assert.Equal(t, security.Anonymous(), got) + assert.Nil(t, got.Credentials()) + + stored, ok := security.FromContext(ctx) + assert.True(t, ok) + assert.Equal(t, security.Anonymous(), stored) + assert.Nil(t, stored.Credentials()) + }) + } +} + +func TestManagerFailureReturnsAnonymous(t *testing.T) { + t.Parallel() + + refusing := &scriptedAuthenticator{name: "x", err: security.ErrInvalidCredentials} + + for name, m := range map[string]security.Manager{ + "no authenticator": security.NewManager(), + "refused": security.NewManager(refusing), + } { + got, err := m.Authenticate(context.Background(), newFakeAuth("alice").withCredentials("S3cr3t!")) + + require.Error(t, err, name) + assert.Equal(t, security.Anonymous(), got, name) + } +} + +// The Authenticator godoc makes ErrUnsupportedCredential the canonical +// runtime bail-out: it is an abstention, never a refusal joined with the +// others (audit CORE-10: a bail-out turned a wrong password into a 400). +func TestManagerTreatsARuntimeBailOutAsAnAbstention(t *testing.T) { + t.Parallel() + + bailOut := func() *scriptedAuthenticator { + return &scriptedAuthenticator{name: "bail", err: fmt.Errorf("out of scope: %w", security.ErrUnsupportedCredential)} + } + + t.Run("bail-out then refusal", func(t *testing.T) { + t.Parallel() + + m := security.NewManager(bailOut(), &scriptedAuthenticator{name: "basic", err: security.ErrInvalidCredentials}) + + _, err := m.Authenticate(context.Background(), newFakeAuth("alice")) + + assert.ErrorIs(t, err, security.ErrAuthenticatorRefused) + assert.ErrorIs(t, err, security.ErrInvalidCredentials) + assert.NotErrorIs(t, err, security.ErrUnsupportedCredential) + }) + + t.Run("bail-outs only", func(t *testing.T) { + t.Parallel() + + m := security.NewManager(bailOut(), bailOut()) + + _, err := m.Authenticate(context.Background(), newFakeAuth("alice")) + + assert.ErrorIs(t, err, security.ErrUnsupportedCredential) + assert.NotErrorIs(t, err, security.ErrAuthenticatorRefused) + }) + + t.Run("bail-out then success", func(t *testing.T) { + t.Parallel() + + winner := newFakeAuth("alice").withAuthenticated() + m := security.NewManager(bailOut(), &scriptedAuthenticator{name: "ok", result: winner}) + + got, err := m.Authenticate(context.Background(), newFakeAuth("alice")) + + require.NoError(t, err) + assert.Equal(t, security.Authentication(winner), got) + }) +} + +func TestManagerTreatsAMissingOrUnauthenticatedResultAsARefusal(t *testing.T) { + t.Parallel() + + for name, result := range map[string]security.Authentication{ + "nil result": nil, + "unauthenticated result": newFakeAuth("alice"), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + violator := &scriptedAuthenticator{name: "violator", result: result} + + _, err := security.NewManager(violator).Authenticate(context.Background(), newFakeAuth("alice")) + + require.Error(t, err) + assert.ErrorIs(t, err, security.ErrAuthenticatorRefused) + assertNoCredentialSentinel(t, err) + + winner := newFakeAuth("alice").withAuthenticated() + m := security.NewManager(&scriptedAuthenticator{name: "violator", result: result}, + &scriptedAuthenticator{name: "ok", result: winner}) + + got, err := m.Authenticate(context.Background(), newFakeAuth("alice")) + + require.NoError(t, err, "the next authenticator still gets its chance") + assert.Equal(t, security.Authentication(winner), got) + }) + } +} + +// Audit probe TestZZAuditNilAttributePanics. +func TestDecideDeniesANilAttribute(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(constDecisionVoter{d: security.DecisionGrant}) + admin := newFakeAuth("root", "ROLE_ADMIN").withAuthenticated() + + for name, attrs := range map[string][]security.Attribute{ + "only nil": {nil}, + "nil after valid": {stringAttr("role:ADMIN"), nil}, + "nil before valid": {nil, stringAttr("role:ADMIN")}, + } { + var err error + + require.NotPanics(t, func() { err = adm.Decide(context.Background(), admin, attrs) }, name) + assert.ErrorIs(t, err, security.ErrAccessDenied, name) + } +} + +func TestDecideTreatsANilAuthenticationAsAnonymous(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(authReadingVoter{}) + + var err error + + require.NotPanics(t, func() { + err = adm.Decide(context.Background(), nil, []security.Attribute{stringAttr("x")}) + }) + assert.ErrorIs(t, err, security.ErrAccessDenied) +} + +func TestDecideCountsAnUndefinedDecisionAsDeny(t *testing.T) { + t.Parallel() + + attrs := []security.Attribute{stringAttr("x")} + alice := newFakeAuth("alice").withAuthenticated() + undefined := constDecisionVoter{d: security.Decision(42)} + grant := constDecisionVoter{d: security.DecisionGrant} + + unanimous := security.NewUnanimousDecisionManager([]security.Voter{undefined}, + security.WithAbstainFallback(security.DecisionGrant)) + assert.ErrorIs(t, unanimous.Decide(context.Background(), alice, attrs), security.ErrAccessDenied, + "an undefined decision is not an abstention the fallback may turn into a grant") + + consensus := security.NewConsensusDecisionManager([]security.Voter{undefined, grant}) + assert.ErrorIs(t, consensus.Decide(context.Background(), alice, attrs), security.ErrAccessDenied, + "one grant against one (undefined) deny is a tie, denied by default") +} + +func TestConsensusDeniesWhenAllAbstain(t *testing.T) { + t.Parallel() + + adm := security.NewConsensusDecisionManager([]security.Voter{ + constDecisionVoter{d: security.DecisionAbstain}, + constDecisionVoter{d: security.DecisionAbstain}, + }, security.WithTieBreak(security.DecisionGrant)) + + err := adm.Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), []security.Attribute{stringAttr("x")}) + + assert.ErrorIs(t, err, security.ErrAccessDenied, "all abstentions deny, even with a granting tie-break") +} + +func TestConstructorsRefuseNilDependencies(t *testing.T) { + t.Parallel() + + voter := constDecisionVoter{d: security.DecisionGrant} + + cases := []struct { + name string + want string + fn func() + }{ + {"engine nil manager", "security: NewEngine: nil Manager", + func() { security.NewEngine(nil, scriptedExtractor{}) }}, + {"engine nil extractor", "security: NewEngine: nil Extractor at index 1", + func() { security.NewEngine(security.NewManager(), scriptedExtractor{}, nil) }}, + {"manager nil authenticator", "security: NewManager: nil Authenticator at index 0", + func() { security.NewManager(nil) }}, + {"affirmative nil voter", "security: NewAffirmativeDecisionManager: nil Voter at index 0", + func() { security.NewAffirmativeDecisionManager(nil) }}, + {"consensus nil voter", "security: NewConsensusDecisionManager: nil Voter at index 1", + func() { security.NewConsensusDecisionManager([]security.Voter{voter, nil}) }}, + {"unanimous nil voter", "security: NewUnanimousDecisionManager: nil Voter at index 0", + func() { security.NewUnanimousDecisionManager([]security.Voter{nil}) }}, + } + + for _, c := range cases { + assert.PanicsWithValue(t, c.want, c.fn, c.name) + } +} diff --git a/engine.go b/engine.go index 9edee989..7cc6b4d6 100644 --- a/engine.go +++ b/engine.go @@ -6,11 +6,19 @@ package security import ( "context" + "errors" "fmt" "go.opentelemetry.io/otel/codes" ) +// errContractViolation marks a component that broke its contract — an +// [Authenticator] or a [Manager] that reported success without an +// authenticated value. It wraps no security sentinel on purpose: the +// transport adapters answer it as an internal error, never as a verdict on +// the credentials. +var errContractViolation = errors.New("security: contract violation") + // Engine is the high-level entry point: it drives a chain of [Extractor]s // against a [Carrier], hands the produced [Authentication] to its [Manager], // and returns a context enriched with the result so downstream handlers can @@ -19,15 +27,41 @@ import ( // Engine is safe for concurrent use. type Engine interface { // Process runs extractors in order and consults the manager on the first - // non-empty result. The returned context always carries an Authentication - // (the anonymous one when nothing was extracted). + // non-empty result. The returned context always carries an + // Authentication: the authenticated one on success, the anonymous one + // when nothing was extracted and on every failure — the extracted + // credentials never leave the Engine. On failure the error says why. Process(ctx context.Context, c Carrier) (context.Context, Authentication, error) } // NewEngine returns an [Engine]. Passing zero extractors is allowed; the // engine will produce the anonymous authentication and return // [ErrNoExtractor] so callers can fail-closed if they wish. +// +// Extractors run in order and the first one that finds a credential wins. +// Put the header-based extractors (bearer, basic) before cookie-based ones +// (session): a browser attaches cookies to cross-site requests by itself, so +// an engine that accepts a cookie session authenticates requests an +// attacker's page can trigger — protect state-changing endpoints reached +// with a cookie session against CSRF, and prefer one engine per audience +// (browser pages, APIs). Per-username throttling or lockout belongs in an +// [Authenticator] wrapper: the Engine never returns the attempted +// credentials. +// +// NewEngine panics if m or one of the extractors is nil: these are +// programming errors that would otherwise surface as a nil dereference on +// the first request. func NewEngine(m Manager, extractors ...Extractor) Engine { + if m == nil { + panic("security: NewEngine: nil Manager") + } + + for i, ex := range extractors { + if ex == nil { + panic(fmt.Sprintf("security: NewEngine: nil Extractor at index %d", i)) + } + } + cp := make([]Extractor, len(extractors)) copy(cp, extractors) @@ -84,22 +118,34 @@ func (e *engine) Process(ctx context.Context, c Carrier) (context.Context, Authe } authed, err := e.manager.Authenticate(ctx, extracted) + if err == nil { + err = requireAuthenticated(authed, "Manager") + } + if err != nil { - // Manager already attached its own span / status; propagate as-is - // after recording the engine-level outcome. We attach the - // (unauthenticated) extracted value to the context so that - // error-mapping middleware can inspect Kind via FromContext for - // richer challenges. + // The extracted value carries the presented credentials (a + // cleartext password, a bearer token): it is dropped here and the + // context gets the anonymous authentication. span.SetStatus(codes.Error, err.Error()) - ctx = WithAuthentication(ctx, extracted) + ctx = WithAuthentication(ctx, Anonymous()) - return ctx, extracted, fmt.Errorf("security.Engine: %w", err) + return ctx, Anonymous(), fmt.Errorf("security.Engine: %w", err) } - span.SetAttributes(AttrAuthenticated.Bool(authed.IsAuthenticated())) + span.SetAttributes(AttrAuthenticated.Bool(true)) ctx = WithAuthentication(ctx, authed) return ctx, authed, nil } + +// requireAuthenticated reports a contract violation when a component that +// returned no error produced no authenticated value. +func requireAuthenticated(auth Authentication, component string) error { + if auth == nil || !auth.IsAuthenticated() { + return fmt.Errorf("%w: %s returned no authenticated value", errContractViolation, component) + } + + return nil +} diff --git a/engine_test.go b/engine_test.go index b4f3426b..d1c82a4c 100644 --- a/engine_test.go +++ b/engine_test.go @@ -83,10 +83,10 @@ func TestEnginePropagatesManagerError(t *testing.T) { ctx, got, err := e.Process(context.Background(), newMapCarrier()) assert.ErrorIs(t, err, security.ErrInvalidCredentials) - assert.Equal(t, Authentication(pending), got, - "failed auth returns the pre-authentication value so adapters can craft a challenge") + assert.Equal(t, security.Anonymous(), got, + "a failed authentication returns Anonymous: the extracted credentials never leave the Engine") stored, _ := security.FromContext(ctx) - assert.Equal(t, Authentication(pending), stored) + assert.Equal(t, security.Anonymous(), stored) } func TestEngineSpanRecordsExtractorAndAuthenticationFlags(t *testing.T) { diff --git a/errors.go b/errors.go index 3a85bd47..15f4adbc 100644 --- a/errors.go +++ b/errors.go @@ -10,6 +10,10 @@ package security // // The unexported method securityError() prevents foreign types from // accidentally satisfying the interface. +// +// An error that wraps none of these sentinels is an internal error (a broken +// contract, a misconfiguration): the transport adapters answer it with +// HTTP 500 / Internal, never as a verdict on the credentials. type SecurityError interface { error securityError() @@ -20,45 +24,78 @@ type SecurityError interface { var ( // ErrInvalidCredentials indicates that the supplied credentials could not // be validated (bad password, unknown user, malformed token). Maps to - // HTTP 401 / gRPC Unauthenticated. + // HTTP 401 — with error="invalid_token" when the request carried a bearer + // token (RFC 6750 §3.1) — and gRPC/Connect Unauthenticated. ErrInvalidCredentials = newSentinel("security: invalid credentials") // ErrClientSecretMismatch indicates that an OAuth2 client presented a - // secret that did not match the registered value. Maps to HTTP 401. + // secret that did not match the registered value. Maps like + // [ErrInvalidCredentials]. ErrClientSecretMismatch = newSentinel("security: oauth2 client secret mismatch") // ErrTokenExpired indicates that a valid token has passed its expiry. - // Maps to HTTP 401. + // Maps like [ErrInvalidCredentials]. ErrTokenExpired = newSentinel("security: token expired") // ErrTokenNotFound indicates that the presented token does not exist in - // the configured storage. Maps to HTTP 401. + // the configured storage. Maps like [ErrInvalidCredentials]. ErrTokenNotFound = newSentinel("security: token not found") - // ErrUnsupportedCredential indicates that no provider recognized the - // credential type. Maps to HTTP 400. + // ErrUnsupportedCredential indicates that no authenticator recognized the + // credential type. The request carries no credential the server can + // validate: it maps to HTTP 401 with a challenge that carries no error + // code (RFC 6750 §3.1, RFC 9110 §15.5.2) and to gRPC/Connect + // Unauthenticated. An [Authenticator] returning it from Authenticate + // bails out: the [Manager] counts that as an abstention, not a refusal. ErrUnsupportedCredential = newSentinel("security: unsupported credential type") // ErrNoExtractor indicates that the [Engine] was configured without any // [Extractor]. The Engine returns the anonymous authentication and this // error so that the caller can distinguish "no extractor" from "all - // extractors found nothing". + // extractors found nothing". Unless the transport tolerates it (anonymous + // fallback), it is a server misconfiguration: HTTP 500 / Internal. ErrNoExtractor = newSentinel("security: no extractor configured") // ErrAuthenticatorRefused is the umbrella error returned by [Manager] // when every supporting [Authenticator] rejected the credential. The // individual errors are joined via errors.Join and reachable through - // errors.Is / errors.As. + // errors.Is / errors.As; transports classify the joined causes. ErrAuthenticatorRefused = newSentinel("security: every authenticator refused the credential") // ErrAccessDenied indicates that authorisation voting denied access. - // Maps to HTTP 403 / gRPC PermissionDenied. + // Maps to HTTP 403 / gRPC PermissionDenied for an authenticated + // principal; the transports answer 401 / Unauthenticated + // ([ErrAuthenticationRequired]) when the refused principal is not + // authenticated (RFC 9110 §15.5.2, §15.5.4). ErrAccessDenied = newSentinel("security: access denied") // ErrInsufficientScope indicates that the principal is authenticated but - // does not carry the OAuth2 scope required for the resource. Maps to - // HTTP 403 with the "insufficient_scope" WWW-Authenticate parameter. + // does not carry the OAuth2 scope required for the resource. Maps like + // [ErrAccessDenied], with error="insufficient_scope" when the request + // carried a bearer token (RFC 6750 §3.1). ErrInsufficientScope = newSentinel("security: insufficient scope") + + // ErrAuthenticationRequired indicates that the request carries no + // credential the server could validate while the resource requires an + // authenticated principal, or that an access decision refused an + // unauthenticated principal. Maps to HTTP 401 with a challenge that + // carries no error code (RFC 6750 §3.1, RFC 9110 §15.5.2) and to + // gRPC/Connect Unauthenticated. + ErrAuthenticationRequired = newSentinel("security: authentication required") + + // ErrInvalidRequest indicates a malformed authentication request, such as + // a singleton credential field (Authorization) sent more than once + // (RFC 9110 §5.3, §5.5). Maps to HTTP 400 — with error="invalid_request" + // when the request carried a bearer token (RFC 6750 §3.1) — and to + // gRPC/Connect InvalidArgument. + ErrInvalidRequest = newSentinel("security: invalid authentication request") + + // ErrUnavailable indicates that the credentials could not be checked + // because a dependency (key set, user store, introspection endpoint) is + // temporarily unavailable. It is not a verdict on the credentials. Maps + // to HTTP 503 / gRPC Unavailable / Connect CodeUnavailable, even when it + // is joined with a credential failure of another authenticator. + ErrUnavailable = newSentinel("security: authentication temporarily unavailable") ) // sentinelError is the concrete type backing every package-level sentinel. diff --git a/errors_test.go b/errors_test.go index 0b96f0af..04d12541 100644 --- a/errors_test.go +++ b/errors_test.go @@ -22,6 +22,13 @@ func TestSentinelErrorsAreDistinct(t *testing.T) { security.ErrTokenExpired, security.ErrTokenNotFound, security.ErrUnsupportedCredential, + security.ErrNoExtractor, + security.ErrAuthenticatorRefused, + security.ErrAccessDenied, + security.ErrInsufficientScope, + security.ErrAuthenticationRequired, + security.ErrInvalidRequest, + security.ErrUnavailable, } for i, a := range sentinels { diff --git a/manager.go b/manager.go index aa115871..e0ea0c3f 100644 --- a/manager.go +++ b/manager.go @@ -16,13 +16,21 @@ import ( // semantics: // // - Authenticators are consulted in registration order. -// - The first authenticator whose Supports() returns true is invoked. -// - On success, the resulting Authentication is returned immediately; -// subsequent authenticators are NOT consulted. -// - On error, the next supporting authenticator is tried; if every one -// fails, the joined error is wrapped in [ErrAuthenticatorRefused]. -// - If no authenticator supports the credential, [ErrUnsupportedCredential] -// is returned. The [Engine] then surfaces it as a 400 in the HTTP adapter. +// - Each authenticator whose Supports() returns true is invoked. +// - On success — an authenticated value and no error — the result is +// returned immediately; subsequent authenticators are NOT consulted. +// - An error wrapping [ErrUnsupportedCredential] is a runtime bail-out: +// the authenticator abstains, as if Supports had returned false. +// - Any other error, and a success without an authenticated value (a +// contract violation), is a refusal: the next supporting authenticator +// is tried. If every one refuses, the refusals are joined and wrapped in +// [ErrAuthenticatorRefused]. +// - If no authenticator supports the credential (or all bail out), +// [ErrUnsupportedCredential] is returned: the transports answer it like +// a request without credentials (HTTP 401 without error code). +// +// On failure the returned Authentication is [Anonymous]: the presented +// credentials never flow back to the caller. // // Manager is safe for concurrent use. type Manager interface { @@ -31,8 +39,15 @@ type Manager interface { // NewManager returns a [Manager] consulting the given authenticators in // order. Passing zero authenticators is allowed; the returned manager will -// always return [ErrUnsupportedCredential]. +// always return [ErrUnsupportedCredential]. NewManager panics if one of the +// authenticators is nil. func NewManager(authenticators ...Authenticator) Manager { + for i, a := range authenticators { + if a == nil { + panic(fmt.Sprintf("security: NewManager: nil Authenticator at index %d", i)) + } + } + cp := make([]Authenticator, len(authenticators)) copy(cp, authenticators) @@ -50,21 +65,21 @@ func (m *manager) Authenticate(ctx context.Context, auth Authentication) (Authen span.SetAttributes(AttrAuthenticatorsCount.Int(len(m.authenticators))) - var ( - anySupported bool - errs []error - ) + var refusals []error for _, a := range m.authenticators { if !a.Supports(auth) { continue } - anySupported = true name := authenticatorName(a) span.AddEvent("authenticator.try", trAttrName(name)) result, err := a.Authenticate(ctx, auth) + if err == nil { + err = requireAuthenticated(result, "authenticator "+name) + } + if err == nil { span.SetAttributes( AttrAuthenticated.Bool(true), @@ -74,23 +89,28 @@ func (m *manager) Authenticate(ctx context.Context, auth Authentication) (Authen return result, nil } - errs = append(errs, fmt.Errorf("%s: %w", name, err)) + if errors.Is(err, ErrUnsupportedCredential) { + // Runtime bail-out: the authenticator abstains. + continue + } + + refusals = append(refusals, fmt.Errorf("%s: %w", name, err)) } - if !anySupported { + if len(refusals) == 0 { err := ErrUnsupportedCredential span.SetStatus(codes.Error, err.Error()) span.RecordError(err) - return auth, err + return Anonymous(), err } - joined := errors.Join(errs...) + joined := errors.Join(refusals...) err := fmt.Errorf("%w: %w", ErrAuthenticatorRefused, joined) span.SetStatus(codes.Error, ErrAuthenticatorRefused.Error()) span.RecordError(err) - return auth, err + return Anonymous(), err } // authenticatorName returns the [NamedAuthenticator] name if implemented, or diff --git a/manager_test.go b/manager_test.go index 9de0bd84..eb254ddb 100644 --- a/manager_test.go +++ b/manager_test.go @@ -22,7 +22,7 @@ func TestManagerReturnsUnsupportedWhenNoAuthenticator(t *testing.T) { got, err := m.Authenticate(context.Background(), auth) assert.ErrorIs(t, err, security.ErrUnsupportedCredential) - assert.Equal(t, Authentication(auth), got, "input MUST flow through on failure") + assert.Equal(t, security.Anonymous(), got, "a failure returns Anonymous, never the presented credentials") } func TestManagerReturnsUnsupportedWhenNoAuthenticatorSupports(t *testing.T) { From 64e13088e0734867dd5b352ded5164c6a2b175d6 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 00:00:27 +0200 Subject: [PATCH 02/29] fix(core): record error classes, not error text, in spans The Engine and Manager spans carried the whole error chain of the authenticators as status description and exception event: the username typed at a Basic prompt (often a password typed in the wrong field) and the wrapped errors of the application's stores reached the trace backend, contrary to the documented secrets policy (OWASP Logging Cheat Sheet, CWE-532). security.ErrorClass(err) classifies a security error, joined errors included, by the first sentinel it wraps in one precedence order shared by every consumer: invalid_request, unavailable, the credential verdicts, authentication_required / unsupported_credential, the access decisions, no_extractor, else the OpenTelemetry fallback _OTHER. Failing core spans now record only that class, as status description and as the OpenTelemetry error.type attribute, and add no exception event. The default transport mappers will answer from the same function. basic.Authenticator no longer puts the typed username in its errors. The AttrPrincipalSubject godoc no longer references a non-existent SetSubjectAttributeMode. Audit: CORE-07, CORE-I11. --- CHANGELOG.md | 13 ++ access_decision_manager.go | 4 +- basic/authenticator.go | 5 +- basic/error_privacy_test.go | 32 +++++ docs/observability.md | 19 ++- docs/security-considerations.md | 4 +- engine.go | 10 +- errorclass.go | 119 ++++++++++++++++ errorclass_test.go | 233 ++++++++++++++++++++++++++++++++ manager.go | 14 +- otel.go | 12 +- 11 files changed, 435 insertions(+), 30 deletions(-) create mode 100644 basic/error_privacy_test.go create mode 100644 errorclass.go create mode 100644 errorclass_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 616eccd8..fd89fe87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -439,6 +439,10 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `security.ErrUnavailable` (the credentials could not be checked because a dependency — key set, user store, introspection endpoint — is temporarily unavailable; not a verdict on the credentials). +- `security.ErrorClass(err)` and the `security.ErrorClass*` constants: one + classification of security errors — joined errors included — shared by + the core spans (`error.type`) and the default HTTP, gRPC and ConnectRPC + error mappers; custom error mappers should use it too. ### Changed @@ -614,6 +618,10 @@ legacy packages (`authentication/`, `authorization/`, the in-tree without an authenticated value as a refusal. `NewEngine`, `NewManager` and the three `AccessDecisionManager` constructors panic on a nil dependency. See [MIGRATION.md](MIGRATION.md). +- Failing core spans (`security.Engine.Process`, + `security.Manager.Authenticate`, `security.AccessDecisionManager.Decide`) + record the class of their error as status description and `error.type` + attribute, and no longer add an `exception` event. - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -1156,3 +1164,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - After a failed authentication the `Engine` stored the extracted `Authentication` — a cleartext password, a bearer token — in the request context; it now stores the anonymous authentication. +- The core spans carried the full error chain of the authenticators — + including the username typed at a Basic prompt (a password typed in the + username field) and wrapped store errors — as status description and + exception event; they now carry the error class only, and + `basic.Authenticator` no longer puts the typed username in its errors. diff --git a/access_decision_manager.go b/access_decision_manager.go index 1d30aeb8..567f4be6 100644 --- a/access_decision_manager.go +++ b/access_decision_manager.go @@ -8,8 +8,6 @@ import ( "context" "fmt" "strings" - - "go.opentelemetry.io/otel/codes" ) // AccessDecisionManager combines the verdicts of multiple [Voter]s into a @@ -131,7 +129,7 @@ func (m *accessDecisionManager) Decide(ctx context.Context, auth Authentication, return nil } - span.SetStatus(codes.Error, ErrAccessDenied.Error()) + recordFailure(span, ErrAccessDenied) return ErrAccessDenied } diff --git a/basic/authenticator.go b/basic/authenticator.go index f05fea2e..25b984eb 100644 --- a/basic/authenticator.go +++ b/basic/authenticator.go @@ -79,8 +79,9 @@ func (a *Authenticator) Authenticate(ctx context.Context, auth security.Authenti if err != nil { // Loader-level errors (db down, unknown user, ...) collapse to a // single ErrInvalidCredentials at the client boundary. The original - // error stays in the chain for observability. - return auth, fmt.Errorf("basic: load user %q: %w (%w)", in.Username(), err, security.ErrInvalidCredentials) + // error stays in the chain for observability; the typed username + // does not (it can be a password typed in the wrong field). + return auth, fmt.Errorf("basic: load user: %w (%w)", err, security.ErrInvalidCredentials) } if user == nil { diff --git a/basic/error_privacy_test.go b/basic/error_privacy_test.go new file mode 100644 index 00000000..4916cdb8 --- /dev/null +++ b/basic/error_privacy_test.go @@ -0,0 +1,32 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package basic_test + +import ( + "context" + "errors" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/basic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-07: the loader error embedded the typed username — sometimes a +// password typed in the username field — and flowed into spans and logs. +func TestAuthenticatorErrorDoesNotEchoTheUsername(t *testing.T) { + t.Parallel() + + const typed = "Summer2026!-my-password" + + a := basic.NewAuthenticator(&stubLoader{err: errors.New("store unreachable")}, newHasher(t)) + + _, err := a.Authenticate(context.Background(), basic.New(typed, "x")) + + require.Error(t, err) + assert.ErrorIs(t, err, security.ErrInvalidCredentials) + assert.NotContains(t, err.Error(), typed) +} diff --git a/docs/observability.md b/docs/observability.md index 2c7c8874..4b1ccb8c 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -134,9 +134,18 @@ storage layer, so apply the same redaction rules you apply to your logs. | Span | When | Attributes | Error status | | --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| `security.Engine.Process` | `Engine.Process` — extract + authenticate | `security.extractors.count` (int), `security.authenticated` (bool) | `ErrNoExtractor`, extractor error, or manager error | -| `security.Manager.Authenticate` | `Manager.Authenticate` — chain authenticators | `security.authenticators.count` (int), `security.authenticated` (bool, on success), `security.authenticator.name` (string, on success); event `authenticator.try` per candidate | `ErrUnsupportedCredential`, `ErrAuthenticatorRefused` | -| `security.AccessDecisionManager.Decide` | `AccessDecisionManager.Decide` | `security.strategy` (string), `security.attributes` (string, joined), `security.decision` (string) | `ErrAccessDenied` when the final decision is not Grant | +| `security.Engine.Process` | `Engine.Process` — extract + authenticate | `security.extractors.count` (int), `security.authenticated` (bool); `error.type` (string, on failure) | `ErrNoExtractor`, extractor error, or manager error | +| `security.Manager.Authenticate` | `Manager.Authenticate` — chain authenticators | `security.authenticators.count` (int), `security.authenticated` (bool, on success), `security.authenticator.name` (string, on success); event `authenticator.try` per candidate; `error.type` (string, on failure) | `ErrUnsupportedCredential`, `ErrAuthenticatorRefused` | +| `security.AccessDecisionManager.Decide` | `AccessDecisionManager.Decide` | `security.strategy` (string), `security.attributes` (string, joined), `security.decision` (string); `error.type` (string, on denial) | `ErrAccessDenied` when the final decision is not Grant | + +A failing core span records **the class of its error, never the error +text**: the status description and the OpenTelemetry `error.type` attribute +are `security.ErrorClass(err)` — `invalid_credentials`, `token_expired`, +`unavailable`, `access_denied`, … or `_OTHER` for an error that wraps no +security sentinel — and no `exception` event is added. The error text can +hold user input (a username, a password typed in the username field) and +the wrapped errors of your stores; it reaches only the caller, which may log +it server-side. `error.type` is not set on success. `security.principal.subject` is a **reserved** attribute key. It is not emitted by default — subject identifiers are PII and high-cardinality. Wire @@ -222,7 +231,9 @@ serving cached metadata opens no span: only downloads do. No span attribute ever carries a secret: cleartext passwords, access or refresh tokens, authorization codes, client secrets, raw session IDs, or -SAML assertions, NameIDs, RelayState handles and binding cookies. +SAML assertions, NameIDs, RelayState handles and binding cookies. The core, +`session` and `samlsp` spans never record an error message either: only its +class. Where correlation is genuinely needed, the value is hashed first (`session.id_hash`). When you add your own instrumentation around this library, keep the same rule. diff --git a/docs/security-considerations.md b/docs/security-considerations.md index 07adcf48..cde366ad 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1100,7 +1100,9 @@ leaks why authentication failed. ## Observability -No secret is ever placed on a span attribute or log line. See +No secret is ever placed on a span attribute or log line, and the core +spans record the class of an error (`security.ErrorClass`), never its text, +which can hold user input such as a typed username. See [observability.md](observability.md) for the secrets policy and the full span catalog. diff --git a/engine.go b/engine.go index 7cc6b4d6..64b2206c 100644 --- a/engine.go +++ b/engine.go @@ -8,8 +8,6 @@ import ( "context" "errors" "fmt" - - "go.opentelemetry.io/otel/codes" ) // errContractViolation marks a component that broke its contract — an @@ -81,8 +79,7 @@ func (e *engine) Process(ctx context.Context, c Carrier) (context.Context, Authe span.SetAttributes(AttrExtractorsCount.Int(len(e.extractors))) if len(e.extractors) == 0 { - span.SetStatus(codes.Error, ErrNoExtractor.Error()) - span.RecordError(ErrNoExtractor) + recordFailure(span, ErrNoExtractor) ctx = WithAuthentication(ctx, Anonymous()) @@ -94,8 +91,7 @@ func (e *engine) Process(ctx context.Context, c Carrier) (context.Context, Authe for _, ex := range e.extractors { auth, err := ex.Extract(ctx, c) if err != nil { - span.SetStatus(codes.Error, err.Error()) - span.RecordError(err) + recordFailure(span, err) ctx = WithAuthentication(ctx, Anonymous()) @@ -126,7 +122,7 @@ func (e *engine) Process(ctx context.Context, c Carrier) (context.Context, Authe // The extracted value carries the presented credentials (a // cleartext password, a bearer token): it is dropped here and the // context gets the anonymous authentication. - span.SetStatus(codes.Error, err.Error()) + recordFailure(span, err) ctx = WithAuthentication(ctx, Anonymous()) diff --git a/errorclass.go b/errorclass.go new file mode 100644 index 00000000..41a47525 --- /dev/null +++ b/errorclass.go @@ -0,0 +1,119 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package security + +import ( + "errors" + + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// Error classes returned by [ErrorClass]. They are the values of the +// OpenTelemetry error.type attribute set on the failing spans of the core, +// and the transport adapters map each class to a status code: +// +// class HTTP gRPC / Connect +// invalid_request 400 InvalidArgument +// unavailable 503 Unavailable +// token_expired, token_not_found, +// client_secret_mismatch, +// invalid_credentials 401 Unauthenticated +// authentication_required, +// unsupported_credential 401 Unauthenticated +// insufficient_scope, access_denied 403 PermissionDenied +// no_extractor, _OTHER 500 Internal +const ( + // ErrorClassInvalidRequest is the class of [ErrInvalidRequest]. + ErrorClassInvalidRequest = "invalid_request" + // ErrorClassUnavailable is the class of [ErrUnavailable]. + ErrorClassUnavailable = "unavailable" + // ErrorClassTokenExpired is the class of [ErrTokenExpired]. + ErrorClassTokenExpired = "token_expired" + // ErrorClassTokenNotFound is the class of [ErrTokenNotFound]. + ErrorClassTokenNotFound = "token_not_found" + // ErrorClassClientSecretMismatch is the class of [ErrClientSecretMismatch]. + ErrorClassClientSecretMismatch = "client_secret_mismatch" + // ErrorClassInvalidCredentials is the class of [ErrInvalidCredentials]. + ErrorClassInvalidCredentials = "invalid_credentials" //nolint:gosec // G101: an error class name, not a credential + // ErrorClassAuthenticationRequired is the class of [ErrAuthenticationRequired]. + ErrorClassAuthenticationRequired = "authentication_required" + // ErrorClassUnsupportedCredential is the class of [ErrUnsupportedCredential]. + ErrorClassUnsupportedCredential = "unsupported_credential" + // ErrorClassInsufficientScope is the class of [ErrInsufficientScope]. + ErrorClassInsufficientScope = "insufficient_scope" + // ErrorClassAccessDenied is the class of [ErrAccessDenied]. + ErrorClassAccessDenied = "access_denied" + // ErrorClassNoExtractor is the class of [ErrNoExtractor]. + ErrorClassNoExtractor = "no_extractor" + // ErrorClassOther is the class of an error that wraps no security + // sentinel: a broken contract or an unexpected failure. It is the + // OpenTelemetry fallback value of error.type. + ErrorClassOther = "_OTHER" +) + +// errorClasses is the classification order: the first sentinel err wraps +// names its class. +// +// - A malformed request is refused whatever else happened. +// - An unavailable dependency comes before any verdict on the credentials: +// joined with another authenticator's refusal, it must not tell the +// client to drop credentials that could be valid. +// - A verdict on the credentials comes before an absence of credentials +// or a bail-out, so one authenticator's refusal is never masked by +// another one's lack of support. +// - Authentication required comes before the access decisions, so an +// unauthenticated principal refused by a decision is asked to +// authenticate (401) rather than refused (403). +var errorClasses = []struct { + err error + class string +}{ + {ErrInvalidRequest, ErrorClassInvalidRequest}, + {ErrUnavailable, ErrorClassUnavailable}, + {ErrTokenExpired, ErrorClassTokenExpired}, + {ErrTokenNotFound, ErrorClassTokenNotFound}, + {ErrClientSecretMismatch, ErrorClassClientSecretMismatch}, + {ErrInvalidCredentials, ErrorClassInvalidCredentials}, + {ErrAuthenticationRequired, ErrorClassAuthenticationRequired}, + {ErrUnsupportedCredential, ErrorClassUnsupportedCredential}, + {ErrInsufficientScope, ErrorClassInsufficientScope}, + {ErrAccessDenied, ErrorClassAccessDenied}, + {ErrNoExtractor, ErrorClassNoExtractor}, +} + +// ErrorClass returns the class of err — one of the ErrorClass* constants — +// following one precedence for every transport, joined errors included: the +// default HTTP, gRPC and ConnectRPC error mappers answer from it, and so +// should a custom mapper. It returns "" for a nil error and +// [ErrorClassOther] for an error that wraps no security sentinel. +// [ErrAuthenticatorRefused] is not a class: it is classified by the +// refusals it joins. +// +// The class is a low-cardinality name free of user input: it is safe to +// record on spans and logs, unlike the error text. +func ErrorClass(err error) string { + if err == nil { + return "" + } + + for _, c := range errorClasses { + if errors.Is(err, c.err) { + return c.class + } + } + + return ErrorClassOther +} + +// recordFailure marks span as failed with the class of err only: the error +// text may hold user input (a username, a store error) and is never +// recorded — neither as the status description nor as an exception event. +func recordFailure(span trace.Span, err error) { + class := ErrorClass(err) + + span.SetStatus(codes.Error, class) + span.SetAttributes(AttrErrorType.String(class)) +} diff --git a/errorclass_test.go b/errorclass_test.go new file mode 100644 index 00000000..73b8740f --- /dev/null +++ b/errorclass_test.go @@ -0,0 +1,233 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package security_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +func TestErrorClass(t *testing.T) { + t.Parallel() + + refused := func(causes ...error) error { + return fmt.Errorf("%w: %w", security.ErrAuthenticatorRefused, errors.Join(causes...)) + } + plain := errors.New("sql: connection refused") + + cases := []struct { + name string + err error + want string + }{ + {"nil", nil, ""}, + {"invalid request", security.ErrInvalidRequest, security.ErrorClassInvalidRequest}, + {"unavailable", security.ErrUnavailable, security.ErrorClassUnavailable}, + {"token expired", security.ErrTokenExpired, security.ErrorClassTokenExpired}, + {"token not found", security.ErrTokenNotFound, security.ErrorClassTokenNotFound}, + {"client secret mismatch", security.ErrClientSecretMismatch, security.ErrorClassClientSecretMismatch}, + {"invalid credentials", security.ErrInvalidCredentials, security.ErrorClassInvalidCredentials}, + {"authentication required", security.ErrAuthenticationRequired, security.ErrorClassAuthenticationRequired}, + {"unsupported credential", security.ErrUnsupportedCredential, security.ErrorClassUnsupportedCredential}, + {"insufficient scope", security.ErrInsufficientScope, security.ErrorClassInsufficientScope}, + {"access denied", security.ErrAccessDenied, security.ErrorClassAccessDenied}, + {"no extractor", security.ErrNoExtractor, security.ErrorClassNoExtractor}, + {"no sentinel", plain, security.ErrorClassOther}, + {"bare refusal", security.ErrAuthenticatorRefused, security.ErrorClassOther}, + {"refusal of a plain error", refused(plain), security.ErrorClassOther}, + {"wrapped verdict", fmt.Errorf("jwt: invalid audience: %w", security.ErrInvalidCredentials), + security.ErrorClassInvalidCredentials}, + {"refusal of a verdict", refused(fmt.Errorf("basic: %w", security.ErrInvalidCredentials)), + security.ErrorClassInvalidCredentials}, + // A malformed request is refused whatever else happened. + {"invalid request before unavailable", errors.Join(security.ErrUnavailable, security.ErrInvalidRequest), + security.ErrorClassInvalidRequest}, + // An outage is not a verdict: a joined refusal of another + // authenticator must not tell the client to drop its credential. + {"unavailable before a verdict", refused(security.ErrTokenNotFound, security.ErrUnavailable), + security.ErrorClassUnavailable}, + {"basic loader outage", fmt.Errorf("basic: load user: %w (%w)", security.ErrUnavailable, security.ErrInvalidCredentials), + security.ErrorClassUnavailable}, + // One authenticator's refusal is not masked by another's bail-out. + {"verdict before unsupported", errors.Join(security.ErrUnsupportedCredential, security.ErrInvalidCredentials), + security.ErrorClassInvalidCredentials}, + {"expired before invalid", errors.Join(security.ErrInvalidCredentials, security.ErrTokenExpired), + security.ErrorClassTokenExpired}, + // An unauthenticated principal refused by a decision is asked to + // authenticate (401), not refused (403). + {"authentication required before access denied", + fmt.Errorf("%w: %w", security.ErrAuthenticationRequired, security.ErrAccessDenied), + security.ErrorClassAuthenticationRequired}, + {"insufficient scope before access denied", + fmt.Errorf("%w: %w", security.ErrInsufficientScope, security.ErrAccessDenied), + security.ErrorClassInsufficientScope}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.want, security.ErrorClass(c.err)) + }) + } +} + +// failureSpanEvidence collects, for the spans of fn, every string a trace +// backend would store about a failure: status descriptions, event names and +// attribute values. +func failureSpanEvidence(spans []sdktrace.ReadOnlySpan) string { + var b strings.Builder + + for _, s := range spans { + b.WriteString(s.Status().Description + "\n") + + for _, kv := range s.Attributes() { + b.WriteString(kv.Value.Emit() + "\n") + } + + for _, ev := range s.Events() { + b.WriteString(ev.Name + "\n") + + for _, kv := range ev.Attributes { + b.WriteString(kv.Value.Emit() + "\n") + } + } + } + + return b.String() +} + +func spanNamed(t *testing.T, spans []sdktrace.ReadOnlySpan, name string) sdktrace.ReadOnlySpan { + t.Helper() + + for _, s := range spans { + if s.Name() == name { + return s + } + } + + require.Failf(t, "span not found", "%s", name) + + return nil +} + +// Audit probe TestZZAuditSpansCarryAuthenticatorErrorText: the Engine and +// Manager spans carried the authenticators' error text (a password typed in +// the username field, a wrapped store error) as status and exception event. +func TestEngineSpanRecordsTheErrorClassNotTheMessage(t *testing.T) { + const typed = "MyP@ssw0rd-typed-in-user-field" + + authn := security.AuthenticatorFunc(func(context.Context, security.Authentication) (security.Authentication, error) { + return nil, fmt.Errorf("basic: load user %q: %w (%w)", typed, + errors.New("sql: no rows for email alice@example.com"), security.ErrInvalidCredentials) + }) + engine := security.NewEngine(security.NewManager(authn), scriptedExtractor{auth: newFakeAuth(typed).withCredentials("x")}) + + spans := spanRecorder(func() { + _, _, err := engine.Process(context.Background(), newMapCarrier()) + require.Error(t, err) + }) + + evidence := failureSpanEvidence(spans) + assert.NotContains(t, evidence, typed) + assert.NotContains(t, evidence, "alice@example.com") + assert.NotContains(t, evidence, "sql:") + assert.NotContains(t, evidence, "exception", "no exception event: its message is the error text") + + for _, name := range []string{"security.Engine.Process", "security.Manager.Authenticate"} { + span := spanNamed(t, spans, name) + assert.Equal(t, codes.Error, span.Status().Code, name) + assert.Equal(t, security.ErrorClassInvalidCredentials, span.Status().Description, name) + assert.Equal(t, security.ErrorClassInvalidCredentials, findAttr(span.Attributes(), security.AttrErrorType), name) + } +} + +func TestEngineSpanRecordsTheClassOfAnExtractorError(t *testing.T) { + const secret = "Basic dXNlcjpTM2NyM3Qh" + + engine := security.NewEngine(security.NewManager(), + scriptedExtractor{err: fmt.Errorf("basic: bad header %q: %w", secret, security.ErrInvalidCredentials)}) + + spans := spanRecorder(func() { + _, _, err := engine.Process(context.Background(), newMapCarrier()) + require.Error(t, err) + }) + + assert.NotContains(t, failureSpanEvidence(spans), secret) + + span := spanNamed(t, spans, "security.Engine.Process") + assert.Equal(t, security.ErrorClassInvalidCredentials, span.Status().Description) + assert.Equal(t, security.ErrorClassInvalidCredentials, findAttr(span.Attributes(), security.AttrErrorType)) +} + +func TestFailureSpansRecordTheirClass(t *testing.T) { + cases := []struct { + name, span, class string + run func() error + }{ + {"no extractor", "security.Engine.Process", security.ErrorClassNoExtractor, func() error { + _, _, err := security.NewEngine(security.NewManager()).Process(context.Background(), newMapCarrier()) + + return err + }}, + {"unsupported", "security.Manager.Authenticate", security.ErrorClassUnsupportedCredential, func() error { + _, err := security.NewManager().Authenticate(context.Background(), newFakeAuth("alice")) + + return err + }}, + {"plain refusal", "security.Manager.Authenticate", security.ErrorClassOther, func() error { + _, err := security.NewManager(&scriptedAuthenticator{name: "x", err: errors.New("boom")}). + Authenticate(context.Background(), newFakeAuth("alice")) + + return err + }}, + {"denied", "security.AccessDecisionManager.Decide", security.ErrorClassAccessDenied, func() error { + return security.NewAffirmativeDecisionManager(constDecisionVoter{d: security.DecisionDeny}). + Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), []security.Attribute{stringAttr("x")}) + }}, + } + + for _, c := range cases { + spans := spanRecorder(func() { require.Error(t, c.run(), c.name) }) + + span := spanNamed(t, spans, c.span) + assert.Equal(t, codes.Error, span.Status().Code, c.name) + assert.Equal(t, c.class, span.Status().Description, c.name) + assert.Equal(t, c.class, findAttr(span.Attributes(), security.AttrErrorType), c.name) + + for _, ev := range span.Events() { + assert.NotEqual(t, "exception", ev.Name, c.name) + } + } +} + +func TestSuccessfulSpansCarryNoErrorType(t *testing.T) { + authed := newFakeAuth("alice").withAuthenticated() + engine := security.NewEngine(security.NewManager(&scriptedAuthenticator{name: "ok", result: authed}), + scriptedExtractor{auth: newFakeAuth("alice")}) + adm := security.NewAffirmativeDecisionManager(constDecisionVoter{d: security.DecisionGrant}) + + spans := spanRecorder(func() { + _, _, err := engine.Process(context.Background(), newMapCarrier()) + require.NoError(t, err) + require.NoError(t, adm.Decide(context.Background(), authed, []security.Attribute{stringAttr("x")})) + }) + + require.Len(t, spans, 3) + + for _, s := range spans { + assert.Empty(t, findAttr(s.Attributes(), security.AttrErrorType), s.Name()) + assert.NotEqual(t, codes.Error, s.Status().Code, s.Name()) + } +} diff --git a/manager.go b/manager.go index e0ea0c3f..5bcc862e 100644 --- a/manager.go +++ b/manager.go @@ -8,8 +8,6 @@ import ( "context" "errors" "fmt" - - "go.opentelemetry.io/otel/codes" ) // Manager orchestrates a chain of [Authenticator]s with first-success-wins @@ -98,17 +96,13 @@ func (m *manager) Authenticate(ctx context.Context, auth Authentication) (Authen } if len(refusals) == 0 { - err := ErrUnsupportedCredential - span.SetStatus(codes.Error, err.Error()) - span.RecordError(err) + recordFailure(span, ErrUnsupportedCredential) - return Anonymous(), err + return Anonymous(), ErrUnsupportedCredential } - joined := errors.Join(refusals...) - err := fmt.Errorf("%w: %w", ErrAuthenticatorRefused, joined) - span.SetStatus(codes.Error, ErrAuthenticatorRefused.Error()) - span.RecordError(err) + err := fmt.Errorf("%w: %w", ErrAuthenticatorRefused, errors.Join(refusals...)) + recordFailure(span, err) return Anonymous(), err } diff --git a/otel.go b/otel.go index 9da6a8df..092f4b23 100644 --- a/otel.go +++ b/otel.go @@ -33,9 +33,10 @@ const ( // authenticated. Value: bool. AttrAuthenticated = attribute.Key("security.authenticated") - // AttrPrincipalSubject is the principal subject. Emission is gated by the - // subject-redaction policy (see SetSubjectAttributeMode) to avoid leaking - // personal data into trace backends; the default is a hashed prefix. + // AttrPrincipalSubject is a reserved key for the principal subject. The + // library never emits it: subject identifiers are personal data and + // high-cardinality. Set it yourself only behind a deliberate, + // low-cardinality (hashed) opt-in. AttrPrincipalSubject = attribute.Key("security.principal.subject") // AttrExtractorsCount counts the extractors tried by an Engine call. @@ -61,4 +62,9 @@ const ( // AttrAttributes is the joined String() form of the Attributes considered // for an authorization decision. Value: string. AttrAttributes = attribute.Key("security.attributes") + + // AttrErrorType is the OpenTelemetry error.type attribute: the + // [ErrorClass] of the error a span failed with, never its text. Set on + // failure only. Value: string. + AttrErrorType = attribute.Key("error.type") ) From 29414bbbc4ef5bcd5074b8fea447bc85912436de Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 00:02:38 +0200 Subject: [PATCH 03/29] fix(core): return copies of authorities Authentication values are documented as immutable snapshots, yet the basic, bearer and session authentications returned their internal authorities slice: any caller could rewrite the authorities of an authenticated value (ROLE_USER into ROLE_ADMIN) for every later reader of the same request context. They now return a copy, and the security.Authentication contract requires implementations to do so instead of recommending to share the backing slice. Audit: CORE-15. --- CHANGELOG.md | 4 ++++ MIGRATION.md | 3 +++ authentication.go | 5 +++-- basic/authentication.go | 11 ++++++++--- basic/authorities_test.go | 26 ++++++++++++++++++++++++++ bearer/authentication.go | 11 ++++++++--- bearer/authorities_test.go | 26 ++++++++++++++++++++++++++ session/authentication.go | 11 ++++++++--- session/authorities_internal_test.go | 25 +++++++++++++++++++++++++ 9 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 basic/authorities_test.go create mode 100644 bearer/authorities_test.go create mode 100644 session/authorities_internal_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fd89fe87..e5d6682e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1169,3 +1169,7 @@ legacy packages (`authentication/`, `authorization/`, the in-tree username field) and wrapped store errors — as status description and exception event; they now carry the error class only, and `basic.Authenticator` no longer puts the typed username in its errors. +- `Authorities()` of the `basic`, `bearer` and `session` authentications + returned their internal slice: a caller could rewrite the authorities of + an authenticated value (`ROLE_USER` → `ROLE_ADMIN`). They now return a + copy, and the `security.Authentication` contract requires it. diff --git a/MIGRATION.md b/MIGRATION.md index 080bd8ed..49e0a098 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1295,3 +1295,6 @@ To migrate: authenticated value on success, and an error wrapping a security sentinel otherwise. 3. Pass non-nil managers, extractors, authenticators, voters and attributes. +4. Make the `Authorities` method of your own `Authentication` types return a + copy (`slices.Clone`): `basic`, `bearer` and `session` now do, so a caller + can no longer rewrite the authorities of an authenticated value. diff --git a/authentication.go b/authentication.go index 066ee521..8457e021 100644 --- a/authentication.go +++ b/authentication.go @@ -38,8 +38,9 @@ type Authentication interface { Credentials() any // Authorities returns the authorities (roles, scopes, permissions) the - // system has granted to this principal. The slice is read-only; - // implementations SHOULD return the same backing slice across calls. + // system has granted to this principal. Implementations MUST NOT expose + // their internal state through it — return a copy (slices.Clone) — so + // that no caller can rewrite the authorities of an immutable snapshot. Authorities() []string // IsAuthenticated reports whether the credentials have been validated by diff --git a/basic/authentication.go b/basic/authentication.go index dcc833f1..f92313de 100644 --- a/basic/authentication.go +++ b/basic/authentication.go @@ -4,7 +4,11 @@ package basic -import "github.com/hyperscale-stack/security" +import ( + "slices" + + "github.com/hyperscale-stack/security" +) // Authentication is the [security.Authentication] produced by the Basic // [Extractor]. It carries the supplied username/password pair before @@ -73,8 +77,9 @@ func (a Authentication) Credentials() any { return a.password } -// Authorities implements [security.Authentication]. -func (a Authentication) Authorities() []string { return a.authorities } +// Authorities implements [security.Authentication]. It returns a copy: the +// Authentication stays immutable whatever the caller does with the slice. +func (a Authentication) Authorities() []string { return slices.Clone(a.authorities) } // IsAuthenticated implements [security.Authentication]. func (a Authentication) IsAuthenticated() bool { return a.authed } diff --git a/basic/authorities_test.go b/basic/authorities_test.go new file mode 100644 index 00000000..c307929b --- /dev/null +++ b/basic/authorities_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package basic_test + +import ( + "testing" + + "github.com/hyperscale-stack/security/basic" + "github.com/stretchr/testify/assert" +) + +// Audit probe TestZZAuditAuthoritiesSliceAliasing: Authorities() exposed the +// backing array, so a caller could rewrite the authorities of an immutable +// Authentication. +func TestAuthoritiesReturnsACopy(t *testing.T) { + t.Parallel() + + auth := basic.New("alice", "pw").WithAuthenticated(&stubUser{subject: "alice"}, []string{"ROLE_USER"}) + + auth.Authorities()[0] = "ROLE_ADMIN" + + assert.Equal(t, []string{"ROLE_USER"}, auth.Authorities()) + assert.Nil(t, basic.New("alice", "pw").Authorities()) +} diff --git a/bearer/authentication.go b/bearer/authentication.go index 8989a3e5..d0d041db 100644 --- a/bearer/authentication.go +++ b/bearer/authentication.go @@ -4,7 +4,11 @@ package bearer -import "github.com/hyperscale-stack/security" +import ( + "slices" + + "github.com/hyperscale-stack/security" +) // Authentication is the [security.Authentication] produced by the bearer // [Extractor]. Before validation it carries only the opaque token; after @@ -73,8 +77,9 @@ func (a Authentication) Credentials() any { return a.token } -// Authorities implements [security.Authentication]. -func (a Authentication) Authorities() []string { return a.authorities } +// Authorities implements [security.Authentication]. It returns a copy: the +// Authentication stays immutable whatever the caller does with the slice. +func (a Authentication) Authorities() []string { return slices.Clone(a.authorities) } // IsAuthenticated implements [security.Authentication]. func (a Authentication) IsAuthenticated() bool { return a.authed } diff --git a/bearer/authorities_test.go b/bearer/authorities_test.go new file mode 100644 index 00000000..dbd4b8f7 --- /dev/null +++ b/bearer/authorities_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package bearer_test + +import ( + "testing" + + "github.com/hyperscale-stack/security/bearer" + "github.com/stretchr/testify/assert" +) + +// Audit probe TestZZAuditAuthoritiesSliceAliasing: Authorities() exposed the +// backing array, so a caller could rewrite the authorities of an immutable +// Authentication. +func TestAuthoritiesReturnsACopy(t *testing.T) { + t.Parallel() + + auth := bearer.New("tok").WithAuthenticated(principal{sub: "alice"}, []string{"scope:read"}, "alice") + + auth.Authorities()[0] = "scope:admin" + + assert.Equal(t, []string{"scope:read"}, auth.Authorities()) + assert.Nil(t, bearer.New("tok").Authorities()) +} diff --git a/session/authentication.go b/session/authentication.go index e08d79e9..93a14ebc 100644 --- a/session/authentication.go +++ b/session/authentication.go @@ -4,7 +4,11 @@ package session -import "github.com/hyperscale-stack/security" +import ( + "slices" + + "github.com/hyperscale-stack/security" +) // Authentication is the [security.Authentication] produced by the session // [Extractor]. Before validation it only carries the decoded [Session]; @@ -57,8 +61,9 @@ func (a Authentication) Principal() security.Principal { // bearer secret the handler should read, so this is always nil. func (a Authentication) Credentials() any { return nil } -// Authorities implements [security.Authentication]. -func (a Authentication) Authorities() []string { return a.authorities } +// Authorities implements [security.Authentication]. It returns a copy: the +// Authentication stays immutable whatever the caller does with the slice. +func (a Authentication) Authorities() []string { return slices.Clone(a.authorities) } // IsAuthenticated implements [security.Authentication]. func (a Authentication) IsAuthenticated() bool { return a.authed } diff --git a/session/authorities_internal_test.go b/session/authorities_internal_test.go new file mode 100644 index 00000000..b327e9ed --- /dev/null +++ b/session/authorities_internal_test.go @@ -0,0 +1,25 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Audit probe TestZZAuditAuthoritiesSliceAliasing: Authorities() exposed the +// backing array, so a caller could rewrite the authorities of an immutable +// Authentication. +func TestAuthoritiesReturnsACopy(t *testing.T) { + t.Parallel() + + auth := newPending(&Session{ID: "sid"}).withAuthenticated(stubPrincipal{sub: "alice"}, []string{"ROLE_USER"}) + + auth.Authorities()[0] = "ROLE_ADMIN" + + assert.Equal(t, []string{"ROLE_USER"}, auth.Authorities()) + assert.Nil(t, newPending(&Session{ID: "sid"}).Authorities()) +} From baec2270e6322726b179a49c69ff9e572a4bf07a Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 00:11:04 +0200 Subject: [PATCH 04/29] fix(voter): compare attribute values, require every listed requirement The attributes passed to an access decision only selected which voter families ran: the role, scope and authority voters checked the names they were built with whatever value the route asked for, and Authenticated()/Anonymous() voted on every attribute. A decision manager shared by several routes granted an ADMIN route to a USER, Authenticated() next to a role voter granted any route to any logged-in principal, HasAnyRole("ADMIN", "MANAGER") granted a MANAGER on Role("ADMIN"), and Not() turned the refusal of an anonymous caller into a grant (CWE-863). Voters now follow Spring Security's semantics. Each votes only on the attributes whose family and value it supports and abstains otherwise; HasAny* grants for the requested names only; Authenticated() and Anonymous() vote only on the new security.IsAuthenticated and security.IsAnonymous state attributes; Not() never grants an unauthenticated principal. So that the new abstentions cannot silently drop a requirement, And() is strict (a grant mixed with an abstention denies) and a decision is denied when one of its attributes is supported by no voter; the decision managers also pass each voter only the attributes it supports. FullyAuthenticated(), identical to Authenticated() for want of any remember-me concept, is removed. Audit: CORE-01, CORE-13, CORE-I2. --- CHANGELOG.md | 29 ++- CLAUDE.md | 14 +- MIGRATION.md | 51 ++++ access_decision_manager.go | 55 +++- access_decision_manager_supported_test.go | 85 ++++++ access_decision_manager_test.go | 4 +- attribute.go | 27 +- docs/architecture.md | 24 +- docs/observability.md | 2 +- docs/security-considerations.md | 28 ++ http/authorize_shared_test.go | 39 +++ otel.go | 5 + voter.go | 17 +- voter/auth_state.go | 65 +++-- voter/auth_state_test.go | 40 +-- voter/authority.go | 48 ++-- voter/authority_test.go | 13 +- voter/composite.go | 170 ++++++++---- voter/composite_test.go | 8 +- voter/doc.go | 12 +- voter/example_test.go | 29 ++- voter/name.go | 69 +++++ voter/permission.go | 4 +- voter/role.go | 57 ++-- voter/role_test.go | 22 +- voter/scope.go | 51 ++-- voter/scope_test.go | 21 +- voter/semantics_test.go | 302 ++++++++++++++++++++++ 28 files changed, 998 insertions(+), 293 deletions(-) create mode 100644 access_decision_manager_supported_test.go create mode 100644 http/authorize_shared_test.go create mode 100644 voter/name.go create mode 100644 voter/semantics_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d6682e..cf0a77d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - **Authorization**: `Voter`/`Decision`/`Attribute`, an `AccessDecisionManager` with Affirmative/Consensus/Unanimous strategies, and a `voter/` catalog (`HasRole`, `HasAnyRole`, `HasScope`, - `HasAuthority`, `HasPermission`, `Authenticated`, `Anonymous`, - `FullyAuthenticated`, `And`/`Or`/`Not`). + `HasAnyScope`, `HasAuthority`, `HasAnyAuthority`, `HasPermission`, + `Authenticated`, `Anonymous`, `And`/`Or`/`Not`). - **HTTP adapter** (`httpsec`): `Middleware`, `Authorize`, a request/response `Carrier`, and a configurable `ErrorMapper`. - **gRPC adapter** (`grpcsec`): unary and stream server interceptors, @@ -443,6 +443,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree classification of security errors — joined errors included — shared by the core spans (`error.type`) and the default HTTP, gRPC and ConnectRPC error mappers; custom error mappers should use it too. +- `security.StateAttribute` with `security.IsAuthenticated` and + `security.IsAnonymous`: the attributes `voter.Authenticated()` and + `voter.Anonymous()` vote on. ### Changed @@ -622,6 +625,16 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `security.Manager.Authenticate`, `security.AccessDecisionManager.Decide`) record the class of their error as status description and `error.type` attribute, and no longer add an `exception` event. +- Voters follow Spring Security's semantics: each votes only on the + attributes whose family and value it supports (`HasRole("ADMIN")` abstains + on `Role("USER")`), `HasAny*` grants for the requested names only, + `Authenticated()`/`Anonymous()` vote only on `security.IsAuthenticated` / + `security.IsAnonymous`, `voter.And` is strict (an inner abstention denies + when the composite votes), `voter.Not` never grants an unauthenticated + principal, and the decision managers deny an attribute that no voter + supports and pass each voter only the attributes it supports. `And`, `Or` + and `Not` panic on a nil or missing voter. See + [MIGRATION.md](MIGRATION.md). - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -765,6 +778,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - The legacy v0 packages: `authentication/`, `authentication/credential/`, `authentication/provider/{dao,oauth2}/`, `authorization/`, and the in-tree `password` package. +- `voter.FullyAuthenticated()`: it behaved exactly like + `voter.Authenticated()` — no remember-me or authentication-strength concept + exists to tell them apart — while promising a stronger check. ### Security @@ -1173,3 +1189,12 @@ legacy packages (`authentication/`, `authorization/`, the in-tree returned their internal slice: a caller could rewrite the authorities of an authenticated value (`ROLE_USER` → `ROLE_ADMIN`). They now return a copy, and the `security.Authentication` contract requires it. +- The stock voters ignored the value of the attribute they were asked to + check, and `voter.Authenticated()` / `voter.Anonymous()` voted on every + attribute: a decision manager shared by several routes granted an ADMIN + route to a USER, and `Authenticated()` next to a role voter granted any + route to any logged-in principal. Voters now compare attribute values, + and the authentication-state voters need their own attributes. +- `voter.Not()` turned the refusal of an unauthenticated principal into a + grant (`Not(HasRole("BANNED"))` admitted anonymous callers); it no longer + does. diff --git a/CLAUDE.md b/CLAUDE.md index 0f48d110..b6ab04a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,10 +207,16 @@ Carrier ──> Extractor ──> Authentication ──> Manager/Authenticator the Manager, returns a context enriched via `WithAuthentication`. - **Authorization** — `Voter` returns `Decision` (Grant/Deny/Abstain) over a set of `Attribute`s; `AccessDecisionManager` combines votes with an - `affirmative` / `consensus` / `unanimous` strategy (mirrors Spring - Security). Stock voters live in `voter/` (`HasRole`, `HasAnyRole`, - `HasScope`, `HasAuthority`, `HasPermission`, `Authenticated`, `Anonymous`, - `FullyAuthenticated`); compose them with `And`/`Or`/`Not`. + `affirmative` / `consensus` / `unanimous` strategy (named after Spring + Security's). The attributes a route lists are the policy: each voter votes + only on the attributes it supports — family AND value (`HasRole("ADMIN")` + abstains on `Role("USER")`) — and the ADM denies when an attribute is + supported by no voter. Stock voters live in `voter/` (`HasRole`, + `HasAnyRole`, `HasScope`, `HasAnyScope`, `HasAuthority`, + `HasAnyAuthority`, `HasPermission`, and `Authenticated` / `Anonymous`, + which vote only on `security.IsAuthenticated` / `security.IsAnonymous`); + compose them with `And` (strict) / `Or` / `Not` (never grants an + unauthenticated principal). Conventions baked into the core: - **Fail closed by default.** No credentials → `Anonymous()`; voters deny diff --git a/MIGRATION.md b/MIGRATION.md index 49e0a098..cc67a612 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1298,3 +1298,54 @@ To migrate: 4. Make the `Authorities` method of your own `Authentication` types return a copy (`slices.Clone`): `basic`, `bearer` and `session` now do, so a caller can no longer rewrite the authorities of an authenticated value. + +### `voter`: voters compare attribute values; dedicated authentication-state attributes + +The attributes passed to `AccessDecisionManager.Decide` (and to +`httpsec.Authorize`, `grpcsec.UnaryAuthorize`, …) only selected *which* +voters ran: a role voter checked the role it was built with whatever role +the route asked for, and `voter.Authenticated()` / `voter.Anonymous()` voted +on every attribute. A decision manager shared by several routes therefore +granted an ADMIN route to a USER, `Authenticated()` next to a role voter +granted any route to any logged-in principal, `HasAnyRole("ADMIN", +"MANAGER")` granted a MANAGER on `Role("ADMIN")`, and `voter.Not()` turned +the refusal of an anonymous caller into a grant. The voters now follow +Spring Security's semantics: the attributes a route lists are its policy, +and each voter votes only on the attributes whose family **and** value it +supports. + +| Policy | Before | After | +| --- | --- | --- | +| ADM `{HasRole("USER"), HasRole("ADMIN")}`, USER on `[Role("ADMIN")]` | granted | denied | +| ADM `{HasRole("USER")}` on `[Role("ADMIN")]` | role USER checked | denied (no voter supports `Role("ADMIN")`) | +| `HasAnyRole("ADMIN", "MANAGER")`, MANAGER on `[Role("ADMIN")]` | granted | denied; granted on `[Role("ADMIN"), Role("MANAGER")]` | +| ADM `{Authenticated(), HasRole("ADMIN")}`, USER on `[Role("ADMIN")]` | granted | denied | +| ADM `{Authenticated()}` on `[Role("X")]` | any logged-in principal | denied — list `security.IsAuthenticated` | +| ADM `{Anonymous()}` on `[Role("X")]` | any anonymous caller | denied — list `security.IsAnonymous` | +| `Not(HasRole("BANNED"))`, anonymous on `[Role("BANNED")]` | granted | denied | +| `And(HasRole("USER"), Not(HasRole("BANNED")))` on `[Role("USER")]` | banned users granted | denied for everyone — list `[Role("USER"), Role("BANNED")]` | +| Unanimous / consensus with an attribute no voter supports | ignored | denied | +| `voter.FullyAuthenticated()` | same as `Authenticated()` | removed | + +To migrate: + +1. Make every route's attributes name exactly what its voters check, and + give the decision manager a voter for every attribute: `Authorize(adm, + security.Role("ADMIN"))` with `voter.HasRole("ADMIN")` in `adm`. An + attribute that no voter supports now denies the request. +2. Require a logged-in or a logged-out caller with an attribute: + `voter.Authenticated()` with `security.IsAuthenticated`, + `voter.Anonymous()` with `security.IsAnonymous`. + `voter.FullyAuthenticated()` is gone: use `voter.Authenticated()`. +3. With `HasAnyRole` / `HasAnyScope` / `HasAnyAuthority`, list every name the + route accepts among its attributes. +4. With `voter.And`, list one attribute per inner requirement: every inner + voter must grant. `And(HasRole("USER"), Not(HasRole("BANNED")))` needs + `[Role("USER"), Role("BANNED")]`; do not share a decision manager holding + such a `Not(HasRole("X"))` policy with a positive `HasRole("X")` voter. +5. Do not rely on `voter.Not` to admit anonymous callers: + `Not(Authenticated())` never grants them — use `voter.Anonymous()` with + `security.IsAnonymous`. +6. Custom voters: `Supports` must check the attribute's value, not only its + type, and `Vote` must consider only the attributes it supports (the + decision manager now passes it only those) and abstain without them. diff --git a/access_decision_manager.go b/access_decision_manager.go index 567f4be6..51a4fdf5 100644 --- a/access_decision_manager.go +++ b/access_decision_manager.go @@ -7,20 +7,30 @@ package security import ( "context" "fmt" + "slices" "strings" ) // AccessDecisionManager combines the verdicts of multiple [Voter]s into a -// single decision. Three strategies are provided, mirroring Spring Security: +// single decision. Three strategies are provided, named after Spring +// Security's: // // - Affirmative — a single [DecisionGrant] grants access; everything else -// denies. Abstentions are ignored. The strictest "fail closed -// by default" policy. +// denies. Abstentions are ignored. With several attributes, one +// satisfied attribute is enough (OR). // - Consensus — the majority wins. Ties default to deny; pass // [WithTieBreak](DecisionGrant) to flip the policy. // - Unanimous — every voter that does not abstain MUST grant. A single // deny refuses; if every voter abstains, the result depends on -// [WithAbstainFallback]. +// [WithAbstainFallback]. Unlike Spring's UnanimousBased, the voters see +// all their attributes at once: it is an AND across voters, while a +// multi-name voter (voter.HasAnyRole) grants when the principal holds +// any of the requested names — express an AND of roles with one voter +// per role, or with voter.And. +// +// Whatever the strategy, a decision is denied when one of its attributes is +// supported by no voter (a requirement nobody checks is never ignored), and +// when there is no attribute or no voter at all. // // Implementations are safe for concurrent use. type AccessDecisionManager interface { @@ -119,8 +129,13 @@ func (m *accessDecisionManager) Decide(ctx context.Context, auth Authentication, } final := DecisionDeny + if !containsNil(attrs) { - final = m.decide(m.votes(ctx, auth, attrs)) + if unsupported := m.unsupported(attrs); unsupported > 0 { + span.SetAttributes(AttrAttributesUnsupported.Int(unsupported)) + } else { + final = m.decide(m.votes(ctx, auth, attrs)) + } } span.SetAttributes(AttrDecision.String(final.String())) @@ -134,19 +149,34 @@ func (m *accessDecisionManager) Decide(ctx context.Context, auth Authentication, return ErrAccessDenied } +// unsupported counts the attributes that no voter supports. +func (m *accessDecisionManager) unsupported(attrs []Attribute) int { + count := 0 + + for _, a := range attrs { + if !slices.ContainsFunc(m.voters, func(v Voter) bool { return v.Supports(a) }) { + count++ + } + } + + return count +} + // votes collects one normalized vote per voter: an abstention for a voter -// that supports none of the attributes, the voter's verdict otherwise. +// that supports none of the attributes, otherwise the voter's verdict on +// the attributes it supports — and only those. func (m *accessDecisionManager) votes(ctx context.Context, auth Authentication, attrs []Attribute) []Decision { votes := make([]Decision, 0, len(m.voters)) for _, v := range m.voters { - if !anySupported(v, attrs) { + supported := supportedBy(v, attrs) + if len(supported) == 0 { votes = append(votes, DecisionAbstain) continue } - votes = append(votes, normalize(v.Vote(ctx, auth, attrs))) + votes = append(votes, normalize(v.Vote(ctx, auth, supported))) } return votes @@ -254,14 +284,17 @@ func containsNil(attrs []Attribute) bool { return false } -func anySupported(v Voter, attrs []Attribute) bool { +// supportedBy returns the attributes v supports, in order. +func supportedBy(v Voter, attrs []Attribute) []Attribute { + var out []Attribute + for _, a := range attrs { if v.Supports(a) { - return true + out = append(out, a) } } - return false + return out } func joinAttributes(attrs []Attribute) string { diff --git a/access_decision_manager_supported_test.go b/access_decision_manager_supported_test.go new file mode 100644 index 00000000..b482dbb1 --- /dev/null +++ b/access_decision_manager_supported_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package security_test + +import ( + "context" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingVoter supports the attributes with the given prefix and records +// the attributes it is asked to vote on. +type recordingVoter struct { + scriptedVoter + + seen []string +} + +func (r *recordingVoter) Vote(ctx context.Context, auth security.Authentication, attrs []security.Attribute) security.Decision { + for _, a := range attrs { + r.seen = append(r.seen, a.String()) + } + + return r.scriptedVoter.Vote(ctx, auth, attrs) +} + +// User decision U2: an attribute that no voter supports denies the +// decision, whatever the strategy or the abstain fallback — it used to be +// ignored, which widened access under unanimous and consensus. +func TestDecideDeniesWhenAnAttributeIsSupportedByNoVoter(t *testing.T) { + alice := newFakeAuth("alice").withAuthenticated() + attrs := []security.Attribute{stringAttr("scope:read"), stringAttr("role:ADMIN"), stringAttr("role:USER")} + + for name, adm := range map[string]security.AccessDecisionManager{ + "affirmative": security.NewAffirmativeDecisionManager(&scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}), + "consensus": security.NewConsensusDecisionManager([]security.Voter{ + &scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}, + }, security.WithTieBreak(security.DecisionGrant)), + "unanimous": security.NewUnanimousDecisionManager([]security.Voter{ + &scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}, + }, security.WithAbstainFallback(security.DecisionGrant)), + } { + spans := spanRecorder(func() { + assert.ErrorIs(t, adm.Decide(context.Background(), alice, attrs), security.ErrAccessDenied, name) + }) + + require.Len(t, spans, 1, name) + assert.Equal(t, "2", findAttr(spans[0].Attributes(), security.AttrAttributesUnsupported), name) + assert.Equal(t, security.ErrorClassAccessDenied, findAttr(spans[0].Attributes(), security.AttrErrorType), name) + } +} + +func TestDecideSupportedAttributesRecordNoUnsupportedCount(t *testing.T) { + adm := security.NewAffirmativeDecisionManager(&scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}) + + spans := spanRecorder(func() { + require.NoError(t, adm.Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), + []security.Attribute{stringAttr("scope:read")})) + }) + + require.Len(t, spans, 1) + assert.Empty(t, findAttr(spans[0].Attributes(), security.AttrAttributesUnsupported)) +} + +// Review nit 14: the decision manager hands each voter only the attributes +// it supports, enforcing the Voter contract for custom voters too. +func TestDecidePassesEachVoterOnlyTheAttributesItSupports(t *testing.T) { + t.Parallel() + + scopes := &recordingVoter{scriptedVoter: scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}} + roles := &recordingVoter{scriptedVoter: scriptedVoter{prefix: "role:", vote: security.DecisionGrant}} + adm := security.NewUnanimousDecisionManager([]security.Voter{scopes, roles}) + + err := adm.Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), + []security.Attribute{stringAttr("scope:read"), stringAttr("role:ADMIN"), stringAttr("scope:write")}) + + require.NoError(t, err) + assert.Equal(t, []string{"scope:read", "scope:write"}, scopes.seen) + assert.Equal(t, []string{"role:ADMIN"}, roles.seen) +} diff --git a/access_decision_manager_test.go b/access_decision_manager_test.go index 7d8f23a1..85142610 100644 --- a/access_decision_manager_test.go +++ b/access_decision_manager_test.go @@ -143,8 +143,10 @@ func TestUnanimousAbstainFallbackDefaultsToDeny(t *testing.T) { func TestUnanimousAbstainFallbackOverride(t *testing.T) { t.Parallel() + // The voter supports the attribute but abstains: the fallback decides. + // (An attribute no voter supports always denies.) adm := security.NewUnanimousDecisionManager([]security.Voter{ - &scriptedVoter{prefix: "role:", vote: security.DecisionGrant}, + &scriptedVoter{prefix: "scope:", vote: security.DecisionAbstain}, }, security.WithAbstainFallback(security.DecisionGrant)) err := adm.Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), diff --git a/attribute.go b/attribute.go index 573e6627..78abdcd2 100644 --- a/attribute.go +++ b/attribute.go @@ -6,10 +6,13 @@ package security import "context" -// Attribute is an opaque authorization predicate carried alongside a request. -// Voters opt-in via [Voter.Supports] and inspect the concrete type through -// type switches. Four concrete attributes are shipped below; applications -// can define their own (they just need to implement String()). +// Attribute names one requirement of a resource: the role, scope, +// authority, permission or authentication state an access decision must +// check. The attributes a route lists are the policy — each [Voter] of the +// [AccessDecisionManager] votes on the attributes it supports, comparing +// their value with the authentication, and an attribute that no voter +// supports denies the decision. Five concrete attributes are shipped below; +// applications can define their own (they just need to implement String()). type Attribute interface { // String returns a stable, log-friendly form of the attribute. It is used // by [AccessDecisionManager] for OTel attributes; it MUST NOT include @@ -80,3 +83,19 @@ func (p PermissionAttribute) String() string { return "permission:" + p.Name } func Permission(name string, predicate func(ctx context.Context, auth Authentication) bool) Attribute { return PermissionAttribute{Name: name, Predicate: predicate} } + +// StateAttribute requires a state of the authentication itself rather than +// an authority. The voter package's Authenticated and Anonymous voters +// evaluate it, and no other stock voter does. +type StateAttribute string + +const ( + // IsAuthenticated requires an authenticated principal. + IsAuthenticated StateAttribute = "authenticated" + // IsAnonymous requires an unauthenticated request — e.g. a signup or + // password-reset request page reserved to logged-out clients. + IsAnonymous StateAttribute = "anonymous" +) + +// String implements [Attribute]. Output is "state:". +func (s StateAttribute) String() string { return "state:" + string(s) } diff --git a/docs/architecture.md b/docs/architecture.md index c7f9a00c..2772002e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,17 +145,27 @@ AccessDecisionManager ──▶ Voter₁ ─┐ nil | ErrAccessDenied ``` -- **`Attribute`** is an opaque authorization predicate handle: `Role`, - `Scope`, `Authority`, `Permission`. -- **`Voter`** inspects an `Authentication` against attributes and returns - `Grant`, `Deny`, or `Abstain`. Voters are pure and concurrency-safe. +- **`Attribute`** names one requirement of the resource: `Role`, `Scope`, + `Authority`, `Permission`, or an authentication state + (`security.IsAuthenticated`, `security.IsAnonymous`). The attributes a + route lists are its policy. +- **`Voter`** votes on the attributes it supports — their family **and** + their value (Spring Security's `RoleVoter` semantics): `HasRole("ADMIN")` + votes on `Role("ADMIN")` and abstains on `Role("USER")`, so one decision + manager can serve several routes. It returns `Grant`, `Deny`, or + `Abstain` (none of its attributes is listed). Voters are pure and + concurrency-safe. - **`AccessDecisionManager`** aggregates voter decisions under a strategy: Affirmative (one grant wins), Consensus (majority), Unanimous (one deny - refuses). + refuses; an AND across voters). It denies a decision when one of the + attributes is supported by no voter, and hands each voter only the + attributes it supports. The `voter/` sub-package ships the standard catalog: `HasRole`, -`HasAnyRole`, `HasScope`, `HasAuthority`, `HasPermission`, `Authenticated`, -`Anonymous`, `FullyAuthenticated`, plus `And`/`Or`/`Not` combinators. +`HasAnyRole`, `HasScope`, `HasAnyScope`, `HasAuthority`, `HasAnyAuthority`, +`HasPermission`, `Authenticated` and `Anonymous` (which vote only on the +state attributes), plus the `And` (strict: every inner voter must grant), +`Or` and `Not` (never grants an unauthenticated principal) combinators. ## Transport adapters diff --git a/docs/observability.md b/docs/observability.md index 4b1ccb8c..6813c505 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -136,7 +136,7 @@ storage layer, so apply the same redaction rules you apply to your logs. | --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------- | | `security.Engine.Process` | `Engine.Process` — extract + authenticate | `security.extractors.count` (int), `security.authenticated` (bool); `error.type` (string, on failure) | `ErrNoExtractor`, extractor error, or manager error | | `security.Manager.Authenticate` | `Manager.Authenticate` — chain authenticators | `security.authenticators.count` (int), `security.authenticated` (bool, on success), `security.authenticator.name` (string, on success); event `authenticator.try` per candidate; `error.type` (string, on failure) | `ErrUnsupportedCredential`, `ErrAuthenticatorRefused` | -| `security.AccessDecisionManager.Decide` | `AccessDecisionManager.Decide` | `security.strategy` (string), `security.attributes` (string, joined), `security.decision` (string); `error.type` (string, on denial) | `ErrAccessDenied` when the final decision is not Grant | +| `security.AccessDecisionManager.Decide` | `AccessDecisionManager.Decide` | `security.strategy` (string), `security.attributes` (string, joined), `security.decision` (string); `security.attributes.unsupported` (int, when an attribute is supported by no voter); `error.type` (string, on denial) | `ErrAccessDenied` when the final decision is not Grant | A failing core span records **the class of its error, never the error text**: the status description and the OpenTelemetry `error.type` attribute diff --git a/docs/security-considerations.md b/docs/security-considerations.md index cde366ad..8aa0bed6 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1091,6 +1091,34 @@ refused: when tenants supply URLs ("test this connection"), log the error and show them a generic message, or the error text becomes an oracle on the internal network. +## Authorization + +Authorization follows Spring Security's voter model, and fails closed: + +- **The attributes are the policy.** Each voter votes only on the + attributes whose family and value it supports: `HasRole("ADMIN")` votes + on `security.Role("ADMIN")` and abstains on `security.Role("USER")`, so a + decision manager shared by several routes grants each route only what it + asks for. `voter.Authenticated()` and `voter.Anonymous()` vote only on + `security.IsAuthenticated` / `security.IsAnonymous`: list the state you + require among the route's attributes. +- **No requirement is ignored.** A decision is denied when one of its + attributes is supported by no voter of the decision manager, when there + is no attribute, and when there is no voter. +- **Composites.** `voter.And` grants only when every inner voter grants — an + inner voter whose attribute the route does not list makes it deny, so list + one attribute per inner requirement. `voter.Not` never turns the refusal + of an unauthenticated principal into a grant: `Not(HasRole("BANNED"))` + admits authenticated principals without the role, not anonymous callers. + Do not share a decision manager holding a `Not(HasRole("X"))` policy with + a positive `HasRole("X")` voter: under the affirmative strategy the + positive voter would grant `Role("X")` routes. +- **Strategies.** Affirmative grants on one satisfied attribute (OR); + unanimous requires every voting voter to grant (AND across voters), but a + multi-name voter such as `HasAnyRole` grants when the principal holds any + of the requested names — use one voter per role, or `voter.And`, for an + AND of roles. + ## Transport error mapping Error mappers return terse, code-first responses. HTTP emits a status code diff --git a/http/authorize_shared_test.go b/http/authorize_shared_test.go new file mode 100644 index 00000000..bbb3e7bd --- /dev/null +++ b/http/authorize_shared_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package httpsec_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/hyperscale-stack/security" + httpsec "github.com/hyperscale-stack/security/http" + "github.com/hyperscale-stack/security/voter" + "github.com/stretchr/testify/assert" +) + +// Audit CORE-01: one decision manager shared by two routes granted the ADMIN +// route to a USER, the attribute only selecting the voter family. +func TestAuthorizeSharedDecisionManagerAcrossRoutes(t *testing.T) { + t.Parallel() + + shared := security.NewAffirmativeDecisionManager(voter.HasRole("USER"), voter.HasRole("ADMIN")) + ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + + mux := http.NewServeMux() + mux.Handle("/admin", httpsec.Authorize(shared, security.Role("ADMIN"))(ok)) + mux.Handle("/user", httpsec.Authorize(shared, security.Role("USER"))(ok)) + + for path, want := range map[string]int{"/admin": http.StatusForbidden, "/user": http.StatusNoContent} { + req := httptest.NewRequest(http.MethodGet, path, nil) + req = req.WithContext(security.WithAuthentication(req.Context(), newAuth("bob", "ROLE_USER").verified())) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + assert.Equal(t, want, rec.Code, path) + } +} diff --git a/otel.go b/otel.go index 092f4b23..70ba39fc 100644 --- a/otel.go +++ b/otel.go @@ -67,4 +67,9 @@ const ( // [ErrorClass] of the error a span failed with, never its text. Set on // failure only. Value: string. AttrErrorType = attribute.Key("error.type") + + // AttrAttributesUnsupported counts the attributes of a refused decision + // that no voter supports (the decision is denied when it is not zero). + // Set only when not zero. Value: int. + AttrAttributesUnsupported = attribute.Key("security.attributes.unsupported") ) diff --git a/voter.go b/voter.go index 1f4036b4..e795a4f7 100644 --- a/voter.go +++ b/voter.go @@ -45,10 +45,19 @@ func (d Decision) String() string { // against a set of [Attribute]s and returns a [Decision]. Voters MUST be // pure (no I/O) and safe for concurrent use. // -// Supports is a fast-path filter: a voter that does not recognize any of the -// passed attributes SHOULD return false to short-circuit the call. When -// Supports returns false, the [AccessDecisionManager] records an abstention -// for the voter without invoking Vote. +// The attributes name what the resource requires, and a voter evaluates the +// ones it understands (Spring Security's RoleVoter / AuthenticatedVoter +// semantics): +// +// - Supports reports whether the voter evaluates attr — its family AND its +// value: HasRole("ADMIN") supports Role("ADMIN"), not Role("USER"). The +// [AccessDecisionManager] denies when an attribute is supported by no +// voter, records an abstention for a voter that supports none of the +// attributes, and passes every other voter only the attributes it +// supports. +// - Vote MUST consider only the attributes it supports, compare their +// value with the authentication, and return [DecisionAbstain] when none +// of them is present. type Voter interface { Supports(attr Attribute) bool Vote(ctx context.Context, auth Authentication, attrs []Attribute) Decision diff --git a/voter/auth_state.go b/voter/auth_state.go index 8be06476..bd4688b7 100644 --- a/voter/auth_state.go +++ b/voter/auth_state.go @@ -6,52 +6,45 @@ package voter import ( "context" + "slices" "github.com/hyperscale-stack/security" ) -// Authenticated returns a voter that grants when the request carries an -// authenticated [security.Authentication]. Useful as the universal -// "must be logged in" check before more specific role/scope voters run. -func Authenticated() security.Voter { return authStateVoter{requireAuth: true} } - -// Anonymous returns a voter that grants when the request is NOT -// authenticated. Useful for endpoints reserved to logged-out clients -// (signup, password-reset request, ...). -func Anonymous() security.Voter { return authStateVoter{requireAuth: false} } - -// FullyAuthenticated is a stricter variant of [Authenticated] reserved for -// flows where "remember-me" / passive sessions must NOT be enough (e.g. -// password change, billing changes). It currently behaves like -// Authenticated; it is the hook a future "remember-me" session flag would -// key off to refuse passively-authenticated requests. -func FullyAuthenticated() security.Voter { return authStateVoter{requireAuth: true, fully: true} } - -type authStateVoter struct { - requireAuth bool - fully bool +// Authenticated returns a voter that evaluates the [security.IsAuthenticated] +// attribute: it grants an authenticated principal and denies anything else. +// It abstains on every other attribute — list [security.IsAuthenticated] +// among the route's attributes to require a logged-in principal. +func Authenticated() security.Voter { return stateVoter{state: security.IsAuthenticated} } + +// Anonymous returns a voter that evaluates the [security.IsAnonymous] +// attribute: it grants an unauthenticated request and denies an +// authenticated one — for endpoints reserved to logged-out clients (signup, +// password-reset request, ...). It abstains on every other attribute. +func Anonymous() security.Voter { return stateVoter{state: security.IsAnonymous} } + +// stateVoter evaluates one authentication-state attribute. +type stateVoter struct { + state security.StateAttribute } -// Supports always returns true: the auth-state voters do not need a specific -// attribute; they observe the request itself. -func (authStateVoter) Supports(security.Attribute) bool { return true } +// Supports reports whether a is the voter's state attribute. +func (v stateVoter) Supports(a security.Attribute) bool { + s, ok := a.(security.StateAttribute) -func (v authStateVoter) Vote(_ context.Context, auth security.Authentication, _ []security.Attribute) security.Decision { - if v.requireAuth { - if !auth.IsAuthenticated() { - return security.DecisionDeny - } - - // The "fully" flag will gain teeth when session.Authentication - // exposes IsRememberMe(); for now any authenticated value qualifies. - _ = v.fully + return ok && s == v.state +} - return security.DecisionGrant +// Vote abstains without the voter's attribute; otherwise it grants when the +// authentication is in the required state. +func (v stateVoter) Vote(_ context.Context, auth security.Authentication, attrs []security.Attribute) security.Decision { + if !slices.ContainsFunc(attrs, v.Supports) { + return security.DecisionAbstain } - if auth.IsAuthenticated() { - return security.DecisionDeny + if auth.IsAuthenticated() == (v.state == security.IsAuthenticated) { + return security.DecisionGrant } - return security.DecisionGrant + return security.DecisionDeny } diff --git a/voter/auth_state_test.go b/voter/auth_state_test.go index 8f4b46b1..9078ba5d 100644 --- a/voter/auth_state_test.go +++ b/voter/auth_state_test.go @@ -17,40 +17,22 @@ func TestAuthenticatedGrantsLoggedIn(t *testing.T) { t.Parallel() v := voter.Authenticated() - assert.Equal(t, security.DecisionGrant, - v.Vote(context.Background(), newAuth("a"), nil)) - assert.Equal(t, security.DecisionDeny, - v.Vote(context.Background(), newAnonymous(), nil)) + attrs := []security.Attribute{security.IsAuthenticated} + + assert.Equal(t, security.DecisionGrant, v.Vote(context.Background(), newAuth("a"), attrs)) + assert.Equal(t, security.DecisionDeny, v.Vote(context.Background(), newAnonymous(), attrs)) + assert.Equal(t, security.DecisionAbstain, v.Vote(context.Background(), newAuth("a"), nil), + "without its attribute the voter has no opinion") } func TestAnonymousGrantsLoggedOut(t *testing.T) { t.Parallel() v := voter.Anonymous() - assert.Equal(t, security.DecisionGrant, - v.Vote(context.Background(), newAnonymous(), nil)) - assert.Equal(t, security.DecisionDeny, - v.Vote(context.Background(), newAuth("a"), nil)) -} - -func TestFullyAuthenticatedCurrentlyTracksAuthenticated(t *testing.T) { - t.Parallel() - - v := voter.FullyAuthenticated() - assert.Equal(t, security.DecisionGrant, - v.Vote(context.Background(), newAuth("a"), nil)) - assert.Equal(t, security.DecisionDeny, - v.Vote(context.Background(), newAnonymous(), nil)) -} - -func TestAuthStateVotersSupportEverything(t *testing.T) { - t.Parallel() + attrs := []security.Attribute{security.IsAnonymous} - // auth-state voters do not consume attribute-specific information; - // they observe the Authentication itself, so Supports must return true - // regardless of attribute family (the ADM will still call Vote). - for _, v := range []security.Voter{voter.Authenticated(), voter.Anonymous(), voter.FullyAuthenticated()} { - assert.True(t, v.Supports(security.Role("X"))) - assert.True(t, v.Supports(security.Scope("y"))) - } + assert.Equal(t, security.DecisionGrant, v.Vote(context.Background(), newAnonymous(), attrs)) + assert.Equal(t, security.DecisionDeny, v.Vote(context.Background(), newAuth("a"), attrs)) + assert.Equal(t, security.DecisionAbstain, v.Vote(context.Background(), newAnonymous(), nil), + "without its attribute the voter has no opinion") } diff --git a/voter/authority.go b/voter/authority.go index 2e33ad04..198a850c 100644 --- a/voter/authority.go +++ b/voter/authority.go @@ -5,50 +5,34 @@ package voter import ( - "context" "slices" "github.com/hyperscale-stack/security" ) -// HasAuthority returns a [security.Voter] that grants when the authenticated -// principal carries the given authority verbatim. No prefix normalisation -// (use [HasRole] / [HasScope] when you want the conventions of those types). +// HasAuthority returns a [security.Voter] bound to one authority: it votes on +// the [security.AuthorityAttribute] naming that authority and abstains on +// every other attribute. It grants when the authenticated principal carries +// the authority verbatim — no prefix normalisation (use [HasRole] / +// [HasScope] when you want the conventions of those types). func HasAuthority(name string) security.Voter { - return authorityVoter{wanted: []string{name}, anyOf: false} + return newAuthorityVoter(name) } -// HasAnyAuthority grants when the principal carries at least one of the -// listed authorities. +// HasAnyAuthority returns a [security.Voter] bound to several authorities: it +// grants when the principal carries at least one of the authorities the +// route requests. func HasAnyAuthority(names ...string) security.Voter { - return authorityVoter{wanted: names, anyOf: true} + return newAuthorityVoter(names...) } -type authorityVoter struct { - wanted []string - anyOf bool +func newAuthorityVoter(names ...string) security.Voter { + return nameVoter{names: slices.Clone(names), nameOf: authorityName, holds: slices.Contains[[]string]} } -func (v authorityVoter) Supports(a security.Attribute) bool { - _, ok := a.(security.AuthorityAttribute) +// authorityName returns the name of an authority attribute. +func authorityName(a security.Attribute) (string, bool) { + n, ok := a.(security.AuthorityAttribute) - return ok -} - -func (v authorityVoter) Vote(_ context.Context, auth security.Authentication, _ []security.Attribute) security.Decision { - if !auth.IsAuthenticated() { - return security.DecisionDeny - } - - for _, want := range v.wanted { - if slices.Contains(auth.Authorities(), want) { - return security.DecisionGrant - } - - if !v.anyOf { - break - } - } - - return security.DecisionDeny + return string(n), ok } diff --git a/voter/authority_test.go b/voter/authority_test.go index 857ce372..09d75d53 100644 --- a/voter/authority_test.go +++ b/voter/authority_test.go @@ -32,17 +32,22 @@ func TestHasAuthoritySupportsOnlyAuthorityAttribute(t *testing.T) { v := voter.HasAuthority("x") assert.True(t, v.Supports(security.Authority("x"))) + assert.False(t, v.Supports(security.Authority("y")), "only the authority it is bound to") assert.False(t, v.Supports(security.Role("ADMIN"))) } -func TestHasAnyAuthorityMatchesOne(t *testing.T) { +func TestHasAnyAuthorityMatchesOneRequestedAuthority(t *testing.T) { t.Parallel() v := voter.HasAnyAuthority("alpha", "beta") - attrs := []security.Attribute{security.Authority("alpha")} assert.Equal(t, security.DecisionGrant, - v.Vote(context.Background(), newAuth("a", "beta"), attrs)) + v.Vote(context.Background(), newAuth("a", "beta"), []security.Attribute{security.Authority("alpha"), security.Authority("beta")})) assert.Equal(t, security.DecisionDeny, - v.Vote(context.Background(), newAuth("a", "gamma"), attrs)) + v.Vote(context.Background(), newAuth("a", "beta"), []security.Attribute{security.Authority("alpha")}), + "beta is held but not requested") + assert.Equal(t, security.DecisionDeny, + v.Vote(context.Background(), newAuth("a", "gamma"), []security.Attribute{security.Authority("alpha")})) + assert.Equal(t, security.DecisionAbstain, + v.Vote(context.Background(), newAuth("a", "gamma"), []security.Attribute{security.Authority("gamma")})) } diff --git a/voter/composite.go b/voter/composite.go index 3af3bcd0..3130f39b 100644 --- a/voter/composite.go +++ b/voter/composite.go @@ -6,34 +6,49 @@ package voter import ( "context" + "fmt" + "slices" "github.com/hyperscale-stack/security" ) -// And combines voters with conjunction semantics: +// And combines voters with strict conjunction semantics. A composite +// supports the attributes its inner voters support, and when it votes: // -// - Any inner Deny => Deny. -// - At least one Grant and no Deny => Grant. -// - All inner voters Abstain => Abstain. +// - Any inner Deny => Deny. +// - Every inner voter grants => Grant. +// - A Grant mixed with an Abstain => Deny. +// - All inner voters Abstain => Abstain. // -// Inner abstentions never block the conjunction: a permission voter that -// does not apply to the current attributes should not single-handedly veto. +// An inner voter abstains when the route does not list its attribute: the +// conjunction then fails closed instead of silently dropping that +// requirement, so list one attribute per inner requirement — e.g. +// [Role("USER"), Role("BANNED")] for And(HasRole("USER"), +// Not(HasRole("BANNED"))). +// +// And panics if it is given no voter or a nil one. func And(voters ...security.Voter) security.Voter { - return compositeVoter{voters: voters, mode: composeAnd} + return newComposite("And", composeAnd, voters) } // Or combines voters with disjunction semantics: // -// - Any inner Grant => Grant. +// - Any inner Grant => Grant. // - No Grant and at least one Deny => Deny. -// - All inner voters Abstain => Abstain. +// - All inner voters Abstain => Abstain. +// +// Or panics if it is given no voter or a nil one. func Or(voters ...security.Voter) security.Voter { - return compositeVoter{voters: voters, mode: composeOr} + return newComposite("Or", composeOr, voters) } -// Not inverts an inner voter: Grant <-> Deny; Abstain stays Abstain. +// Not inverts an inner voter: Grant becomes Deny and Deny becomes Grant — but +// only for an authenticated principal. The stock voters deny unauthenticated +// requests; Not never turns that refusal into a grant, so Not(Authenticated()) +// does not admit anonymous callers (use [Anonymous] with +// security.IsAnonymous). Abstain stays Abstain. Not panics on a nil voter. func Not(inner security.Voter) security.Voter { - return compositeVoter{voters: []security.Voter{inner}, mode: composeNot} + return newComposite("Not", composeNot, []security.Voter{inner}) } type composeMode int @@ -49,8 +64,21 @@ type compositeVoter struct { mode composeMode } -// Supports returns true when at least one inner voter does, plus always for -// the auth-state voters embedded inside the composite (they Supports anything). +func newComposite(constructor string, mode composeMode, voters []security.Voter) compositeVoter { + if len(voters) == 0 { + panic("voter: " + constructor + ": no voter") + } + + for i, v := range voters { + if v == nil { + panic(fmt.Sprintf("voter: %s: nil voter at index %d", constructor, i)) + } + } + + return compositeVoter{voters: slices.Clone(voters), mode: mode} +} + +// Supports returns true when at least one inner voter does. func (c compositeVoter) Supports(a security.Attribute) bool { for _, v := range c.voters { if v.Supports(a) { @@ -61,58 +89,98 @@ func (c compositeVoter) Supports(a security.Attribute) bool { return false } +// Vote combines the inner votes according to the composite's mode. func (c compositeVoter) Vote(ctx context.Context, auth security.Authentication, attrs []security.Attribute) security.Decision { - if c.mode == composeNot { - switch c.voters[0].Vote(ctx, auth, attrs) { + switch c.mode { + case composeNot: + return not(c.voters[0].Vote(ctx, auth, attrs), auth) + case composeAnd: + return and(c.votes(ctx, auth, attrs)) + case composeOr: + return or(c.votes(ctx, auth, attrs)) + } + + return security.DecisionDeny +} + +func (c compositeVoter) votes(ctx context.Context, auth security.Authentication, attrs []security.Attribute) []security.Decision { + out := make([]security.Decision, len(c.voters)) + + for i, v := range c.voters { + out[i] = v.Vote(ctx, auth, attrs) + } + + return out +} + +// and is the strict conjunction: Grant only when every vote is a Grant. An +// undefined decision counts as Deny. +func and(votes []security.Decision) security.Decision { + grants, abstains := 0, 0 + + for _, d := range votes { + switch d { case security.DecisionGrant: - return security.DecisionDeny - case security.DecisionDeny: - return security.DecisionGrant + grants++ case security.DecisionAbstain: - return security.DecisionAbstain + abstains++ + case security.DecisionDeny: + return security.DecisionDeny + default: + return security.DecisionDeny } } - var ( - sawGrant bool - sawDeny bool - ) + switch len(votes) { + case abstains: + return security.DecisionAbstain + case grants: + return security.DecisionGrant + default: + return security.DecisionDeny + } +} - for _, v := range c.voters { - switch v.Vote(ctx, auth, attrs) { +// or grants on any Grant; otherwise any Deny (or undefined decision) denies; +// all abstentions abstain. +func or(votes []security.Decision) security.Decision { + sawDeny := false + + for _, d := range votes { + switch d { case security.DecisionGrant: - sawGrant = true - case security.DecisionDeny: - sawDeny = true + return security.DecisionGrant case security.DecisionAbstain: // ignore + case security.DecisionDeny: + sawDeny = true + default: + sawDeny = true } } - switch c.mode { - case composeAnd: - switch { - case sawDeny: - return security.DecisionDeny - case sawGrant: - return security.DecisionGrant - default: - return security.DecisionAbstain - } - case composeOr: - switch { - case sawGrant: + if sawDeny { + return security.DecisionDeny + } + + return security.DecisionAbstain +} + +// not inverts d, but never grants an unauthenticated principal. An undefined +// decision counts as Deny. +func not(d security.Decision, auth security.Authentication) security.Decision { + switch d { + case security.DecisionGrant: + return security.DecisionDeny + case security.DecisionDeny: + if auth.IsAuthenticated() { return security.DecisionGrant - case sawDeny: - return security.DecisionDeny - default: - return security.DecisionAbstain } - case composeNot: - // Unreachable: composeNot is handled by the early return above; the - // case is here only to make the switch exhaustive. + + return security.DecisionDeny + case security.DecisionAbstain: return security.DecisionAbstain + default: + return security.DecisionDeny } - - return security.DecisionAbstain } diff --git a/voter/composite_test.go b/voter/composite_test.go index 79218cde..2d9403bc 100644 --- a/voter/composite_test.go +++ b/voter/composite_test.go @@ -33,7 +33,11 @@ func TestAndTruthTable(t *testing.T) { {"all_grant", []security.Decision{security.DecisionGrant, security.DecisionGrant}, security.DecisionGrant}, {"one_deny", []security.Decision{security.DecisionGrant, security.DecisionDeny}, security.DecisionDeny}, {"all_abstain", []security.Decision{security.DecisionAbstain, security.DecisionAbstain}, security.DecisionAbstain}, - {"grant_with_abstain", []security.Decision{security.DecisionGrant, security.DecisionAbstain}, security.DecisionGrant}, + {"grant_with_abstain", []security.Decision{security.DecisionGrant, security.DecisionAbstain}, security.DecisionDeny}, + {"abstain_with_deny", []security.Decision{security.DecisionAbstain, security.DecisionDeny}, security.DecisionDeny}, + {"undefined", []security.Decision{security.DecisionGrant, security.Decision(42)}, security.DecisionDeny}, + {"only_undefined", []security.Decision{security.Decision(42)}, security.DecisionDeny}, + {"undefined_with_abstain", []security.Decision{security.DecisionAbstain, security.Decision(42)}, security.DecisionDeny}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -62,6 +66,8 @@ func TestOrTruthTable(t *testing.T) { {"one_grant", []security.Decision{security.DecisionGrant, security.DecisionDeny}, security.DecisionGrant}, {"all_abstain", []security.Decision{security.DecisionAbstain, security.DecisionAbstain}, security.DecisionAbstain}, {"deny_with_abstain", []security.Decision{security.DecisionDeny, security.DecisionAbstain}, security.DecisionDeny}, + {"grant_with_abstain", []security.Decision{security.DecisionAbstain, security.DecisionGrant}, security.DecisionGrant}, + {"undefined", []security.Decision{security.Decision(42), security.DecisionAbstain}, security.DecisionDeny}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/voter/doc.go b/voter/doc.go index ea91291a..04fbf202 100644 --- a/voter/doc.go +++ b/voter/doc.go @@ -5,9 +5,15 @@ // Package voter ships the catalog of stock [security.Voter] // implementations consumed by [security.AccessDecisionManager]. // -// Each voter Supports a single attribute family (roles, scopes, -// authorities, permissions, or authentication state). Compose them through -// And/Or/Not for richer policies. +// The attributes a route lists name its requirements; each voter is bound to +// values and votes only on the attributes whose family and value it supports +// (Spring Security's RoleVoter semantics): HasRole("ADMIN") votes on +// security.Role("ADMIN") and abstains on security.Role("USER"), so one +// decision manager can safely serve several routes. Authenticated and +// Anonymous vote only on the security.IsAuthenticated and +// security.IsAnonymous attributes. Compose voters through And (strict: every +// inner voter must grant), Or and Not (which never grants an unauthenticated +// principal) for richer policies. // // Voters are pure (no I/O) and safe for concurrent use. package voter diff --git a/voter/example_test.go b/voter/example_test.go index a7cf9ac5..75b0b14c 100644 --- a/voter/example_test.go +++ b/voter/example_test.go @@ -12,10 +12,10 @@ import ( "github.com/hyperscale-stack/security/voter" ) -// Example shows the canonical authorization pipeline: bind the expected -// permission into the voter at construction time and call Decide on the -// known authentication. Attributes are matched against Voter.Supports to -// activate the voter; the voter itself knows what to check. +// Example shows the canonical authorization pipeline: the attributes name +// what the resource requires, and each voter of the decision manager votes +// on the attributes it supports — its family and its value — comparing them +// with the authentication. func Example() { adminOnly := security.NewAffirmativeDecisionManager( voter.HasRole("ADMIN"), @@ -36,21 +36,30 @@ func Example() { // write:mail: security: access denied } -// Example_compose demonstrates the And/Or/Not combinators. +// Example_compose demonstrates the And/Or/Not combinators. A composite votes +// on the attributes its inner voters support, and And grants only when every +// inner voter grants: list one attribute per inner requirement. func Example_compose() { adm := security.NewAffirmativeDecisionManager( voter.And( voter.Authenticated(), voter.HasAnyRole("ADMIN", "MANAGER"), + voter.Not(voter.HasRole("BANNED")), ), ) - auth := newAuth("bob", "ROLE_MANAGER") - err := adm.Decide(context.Background(), auth, []security.Attribute{ - security.Role("ADMIN"), - }) - fmt.Println("manager:", err) + managerRoute := []security.Attribute{security.IsAuthenticated, security.Role("MANAGER"), security.Role("BANNED")} + adminRoute := []security.Attribute{security.IsAuthenticated, security.Role("ADMIN"), security.Role("BANNED")} + + manager := newAuth("bob", "ROLE_MANAGER") + bannedManager := newAuth("eve", "ROLE_MANAGER", "ROLE_BANNED") + + fmt.Println("manager:", adm.Decide(context.Background(), manager, managerRoute)) + fmt.Println("banned manager:", adm.Decide(context.Background(), bannedManager, managerRoute)) + fmt.Println("manager on the admin route:", adm.Decide(context.Background(), manager, adminRoute)) // Output: // manager: + // banned manager: security: access denied + // manager on the admin route: security: access denied } diff --git a/voter/name.go b/voter/name.go new file mode 100644 index 00000000..3c4c0eb8 --- /dev/null +++ b/voter/name.go @@ -0,0 +1,69 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package voter + +import ( + "context" + "slices" + + "github.com/hyperscale-stack/security" +) + +// nameVoter is the role, scope and authority voter. Following Spring +// Security's RoleVoter, it is bound to a set of names and votes only on the +// attributes of its family whose name is in that set: the attributes a route +// lists are the policy, the voter checks the ones it knows. +type nameVoter struct { + names []string + // nameOf returns the name of an attribute of the voter's family. + nameOf func(security.Attribute) (string, bool) + // holds reports whether the authorities grant the named requirement. + holds func(authorities []string, name string) bool +} + +// Supports reports whether a is an attribute of the voter's family whose +// name the voter is bound to. +func (v nameVoter) Supports(a security.Attribute) bool { + name, ok := v.nameOf(a) + + return ok && slices.Contains(v.names, name) +} + +// Vote abstains when attrs carries none of the voter's names, denies an +// unauthenticated principal, and grants when the principal holds at least +// one of the requested names. +func (v nameVoter) Vote(_ context.Context, auth security.Authentication, attrs []security.Attribute) security.Decision { + requested := v.requested(attrs) + if len(requested) == 0 { + return security.DecisionAbstain + } + + if !auth.IsAuthenticated() { + return security.DecisionDeny + } + + authorities := auth.Authorities() + + for _, name := range requested { + if v.holds(authorities, name) { + return security.DecisionGrant + } + } + + return security.DecisionDeny +} + +// requested returns the names of the supported attributes of attrs. +func (v nameVoter) requested(attrs []security.Attribute) []string { + var out []string + + for _, a := range attrs { + if name, ok := v.nameOf(a); ok && slices.Contains(v.names, name) { + out = append(out, name) + } + } + + return out +} diff --git a/voter/permission.go b/voter/permission.go index 583fd5fc..937e19d3 100644 --- a/voter/permission.go +++ b/voter/permission.go @@ -11,7 +11,9 @@ import ( ) // HasPermission returns a [security.Voter] that evaluates every -// [security.PermissionAttribute] passed to it via its embedded predicate. +// [security.PermissionAttribute] passed to it via its embedded predicate: +// the attribute carries its own requirement, so the voter supports every +// permission attribute and abstains on the other families. // // Vote semantics: // - Unauthenticated => Deny. diff --git a/voter/role.go b/voter/role.go index d443f73d..9f16090b 100644 --- a/voter/role.go +++ b/voter/role.go @@ -5,64 +5,41 @@ package voter import ( - "context" "slices" "github.com/hyperscale-stack/security" ) -// HasRole returns a [security.Voter] that grants when the authenticated -// principal carries the given role (matched on Authorities() with the -// Spring-style ROLE_ prefix). Unauthenticated requests always vote Deny; -// foreign attribute families produce Abstain. +// HasRole returns a [security.Voter] bound to one role: it votes on the +// [security.RoleAttribute] naming that role and abstains on every other +// attribute. It grants when the authenticated principal carries the role +// (matched on Authorities() with the Spring-style ROLE_ prefix) and denies +// otherwise; unauthenticated requests always vote Deny. // // The voter compares against [security.Authentication.Authorities] in two // shapes: with and without the ROLE_ prefix, so applications can use either // convention in their user store. func HasRole(role string) security.Voter { - return roleVoter{wanted: []string{role}, anyOf: false} + return newRoleVoter(role) } -// HasAnyRole grants when the principal carries at least one of the listed -// roles. Same comparison rules as [HasRole]. +// HasAnyRole returns a [security.Voter] bound to several roles: it votes on +// the role attributes naming one of them and grants when the principal +// carries at least one of the roles the route requests (OR, like Spring's +// RoleVoter). Same comparison rules as [HasRole]. func HasAnyRole(roles ...string) security.Voter { - return roleVoter{wanted: roles, anyOf: true} + return newRoleVoter(roles...) } -type roleVoter struct { - wanted []string - anyOf bool +func newRoleVoter(roles ...string) security.Voter { + return nameVoter{names: slices.Clone(roles), nameOf: roleName, holds: hasRole} } -func (v roleVoter) Supports(a security.Attribute) bool { - _, ok := a.(security.RoleAttribute) +// roleName returns the bare name of a role attribute. +func roleName(a security.Attribute) (string, bool) { + r, ok := a.(security.RoleAttribute) - return ok -} - -func (v roleVoter) Vote(_ context.Context, auth security.Authentication, attrs []security.Attribute) security.Decision { - if !auth.IsAuthenticated() { - return security.DecisionDeny - } - - for _, want := range v.wanted { - if hasRole(auth.Authorities(), want) { - return security.DecisionGrant - } - - if !v.anyOf { - // Single-role mode: every wanted role MUST match; one miss is - // enough to deny. But there's only one entry, so the loop is - // degenerate — fall through to deny below. - break - } - } - // Touch attrs to keep the parameter meaningful in tests that supply - // attributes; voters do not need to inspect them when the role list - // is pre-bound. - _ = attrs - - return security.DecisionDeny + return r.Name(), ok } // hasRole reports whether authorities contains role either verbatim or with diff --git a/voter/role_test.go b/voter/role_test.go index d3034937..ea3b2223 100644 --- a/voter/role_test.go +++ b/voter/role_test.go @@ -18,7 +18,9 @@ func TestHasRoleSupportsOnlyRoleAttribute(t *testing.T) { v := voter.HasRole("ADMIN") assert.True(t, v.Supports(security.Role("ADMIN"))) + assert.False(t, v.Supports(security.Role("USER")), "only the role it is bound to") assert.False(t, v.Supports(security.Scope("read"))) + assert.False(t, v.Supports(security.Scope("ADMIN"))) } func TestHasRoleMatchesEitherPrefixedOrBare(t *testing.T) { @@ -43,26 +45,28 @@ func TestHasRoleDeniesUnauthenticated(t *testing.T) { assert.Equal(t, security.DecisionDeny, got) } -func TestHasAnyRoleMatchesAtLeastOne(t *testing.T) { +func TestHasAnyRoleMatchesAtLeastOneRequestedRole(t *testing.T) { t.Parallel() v := voter.HasAnyRole("ADMIN", "OWNER") - attrs := []security.Attribute{security.Role("ADMIN")} cases := []struct { - name string - auth fakeAuth - want security.Decision + name string + auth fakeAuth + attrs []security.Attribute + want security.Decision }{ - {"first_matches", newAuth("a", "ROLE_ADMIN"), security.DecisionGrant}, - {"second_matches", newAuth("a", "OWNER"), security.DecisionGrant}, - {"neither", newAuth("a", "USER"), security.DecisionDeny}, + {"first_matches", newAuth("a", "ROLE_ADMIN"), []security.Attribute{security.Role("ADMIN")}, security.DecisionGrant}, + {"second_matches", newAuth("a", "OWNER"), []security.Attribute{security.Role("ADMIN"), security.Role("OWNER")}, security.DecisionGrant}, + {"held_but_not_requested", newAuth("a", "OWNER"), []security.Attribute{security.Role("ADMIN")}, security.DecisionDeny}, + {"neither", newAuth("a", "USER"), []security.Attribute{security.Role("ADMIN"), security.Role("OWNER")}, security.DecisionDeny}, + {"not_bound", newAuth("a", "ROLE_USER"), []security.Attribute{security.Role("USER")}, security.DecisionAbstain}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, c.want, v.Vote(context.Background(), c.auth, attrs)) + assert.Equal(t, c.want, v.Vote(context.Background(), c.auth, c.attrs)) }) } } diff --git a/voter/scope.go b/voter/scope.go index d2cb780b..b8f846ad 100644 --- a/voter/scope.go +++ b/voter/scope.go @@ -5,58 +5,41 @@ package voter import ( - "context" "slices" "strings" "github.com/hyperscale-stack/security" ) -// HasScope returns a [security.Voter] that grants when the authenticated -// principal carries the given OAuth2 scope. Scope matching is exact and -// supports two storage conventions on [security.Authentication.Authorities]: +// HasScope returns a [security.Voter] bound to one OAuth2 scope: it votes on +// the [security.ScopeAttribute] naming that scope and abstains on every other +// attribute. It grants when the authenticated principal carries the scope +// and denies otherwise; unauthenticated requests always vote Deny. Scope +// matching is exact and supports two storage conventions on +// [security.Authentication.Authorities]: // // - bare scope name ("read:mail") // - "scope:" prefix ("scope:read:mail") -// -// Unauthenticated requests always vote Deny; non-scope attributes Abstain. func HasScope(scope string) security.Voter { - return scopeVoter{wanted: []string{scope}, anyOf: false} + return newScopeVoter(scope) } -// HasAnyScope grants when the principal carries at least one of the listed -// scopes. Same comparison rules as [HasScope]. +// HasAnyScope returns a [security.Voter] bound to several scopes: it grants +// when the principal carries at least one of the scopes the route requests. +// Same comparison rules as [HasScope]. func HasAnyScope(scopes ...string) security.Voter { - return scopeVoter{wanted: scopes, anyOf: true} + return newScopeVoter(scopes...) } -type scopeVoter struct { - wanted []string - anyOf bool +func newScopeVoter(scopes ...string) security.Voter { + return nameVoter{names: slices.Clone(scopes), nameOf: scopeName, holds: hasScope} } -func (v scopeVoter) Supports(a security.Attribute) bool { - _, ok := a.(security.ScopeAttribute) - - return ok -} - -func (v scopeVoter) Vote(_ context.Context, auth security.Authentication, _ []security.Attribute) security.Decision { - if !auth.IsAuthenticated() { - return security.DecisionDeny - } - - for _, want := range v.wanted { - if hasScope(auth.Authorities(), want) { - return security.DecisionGrant - } - - if !v.anyOf { - break - } - } +// scopeName returns the name of a scope attribute. +func scopeName(a security.Attribute) (string, bool) { + s, ok := a.(security.ScopeAttribute) - return security.DecisionDeny + return s.Name(), ok } func hasScope(authorities []string, scope string) bool { diff --git a/voter/scope_test.go b/voter/scope_test.go index eec2a986..8d0946df 100644 --- a/voter/scope_test.go +++ b/voter/scope_test.go @@ -44,29 +44,32 @@ func TestHasScopeSupportsOnlyScopeAttribute(t *testing.T) { v := voter.HasScope("read") assert.True(t, v.Supports(security.Scope("read"))) + assert.False(t, v.Supports(security.Scope("write")), "only the scope it is bound to") assert.False(t, v.Supports(security.Role("ADMIN"))) } -func TestHasAnyScopeMatchesAtLeastOne(t *testing.T) { +func TestHasAnyScopeMatchesAtLeastOneRequestedScope(t *testing.T) { t.Parallel() v := voter.HasAnyScope("read", "write") - attrs := []security.Attribute{security.Scope("read")} cases := []struct { - name string - auth fakeAuth - want security.Decision + name string + auth fakeAuth + attrs []security.Attribute + want security.Decision }{ - {"first", newAuth("a", "read"), security.DecisionGrant}, - {"second", newAuth("a", "write"), security.DecisionGrant}, - {"none", newAuth("a", "admin"), security.DecisionDeny}, + {"first", newAuth("a", "read"), []security.Attribute{security.Scope("read")}, security.DecisionGrant}, + {"second", newAuth("a", "write"), []security.Attribute{security.Scope("read"), security.Scope("write")}, security.DecisionGrant}, + {"held_but_not_requested", newAuth("a", "write"), []security.Attribute{security.Scope("read")}, security.DecisionDeny}, + {"none", newAuth("a", "admin"), []security.Attribute{security.Scope("read")}, security.DecisionDeny}, + {"not_bound", newAuth("a", "admin"), []security.Attribute{security.Scope("admin")}, security.DecisionAbstain}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, c.want, v.Vote(context.Background(), c.auth, attrs)) + assert.Equal(t, c.want, v.Vote(context.Background(), c.auth, c.attrs)) }) } } diff --git a/voter/semantics_test.go b/voter/semantics_test.go new file mode 100644 index 00000000..580d2c03 --- /dev/null +++ b/voter/semantics_test.go @@ -0,0 +1,302 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package voter_test + +import ( + "context" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/voter" + "github.com/stretchr/testify/assert" +) + +// pending is an unauthenticated value that nonetheless carries authorities: +// it proves the stock voters check IsAuthenticated, not only the authorities. +func pending(authorities ...string) fakeAuth { + return fakeAuth{pr: security.AnonymousPrincipal, authorities: authorities} +} + +func decide(adm security.AccessDecisionManager, auth security.Authentication, attrs ...security.Attribute) error { + return adm.Decide(context.Background(), auth, attrs) +} + +// Audit probe TestZZAuditSharedADMIgnoresAttributeValue: the attribute only +// selected the voter family, so a decision manager shared by two routes +// granted the ADMIN route to a USER. +func TestSharedDecisionManagerComparesAttributeValues(t *testing.T) { + t.Parallel() + + shared := security.NewAffirmativeDecisionManager(voter.HasRole("USER"), voter.HasRole("ADMIN")) + user := newAuth("bob", "ROLE_USER") + admin := newAuth("root", "ROLE_ADMIN") + + assert.ErrorIs(t, decide(shared, user, security.Role("ADMIN")), security.ErrAccessDenied) + assert.NoError(t, decide(shared, admin, security.Role("ADMIN"))) + assert.NoError(t, decide(shared, user, security.Role("USER"))) + assert.ErrorIs(t, decide(shared, admin, security.Role("USER")), security.ErrAccessDenied) +} + +func TestVotersAbstainOnValuesTheyAreNotBoundTo(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + v security.Voter + auth fakeAuth + attrs []security.Attribute + }{ + {"role", voter.HasRole("USER"), newAuth("bob", "ROLE_USER"), []security.Attribute{security.Role("ADMIN")}}, + {"scope", voter.HasScope("read"), newAuth("svc", "scope:read"), []security.Attribute{security.Scope("admin:write")}}, + {"authority", voter.HasAuthority("PERM_READ"), newAuth("svc", "PERM_READ"), []security.Attribute{security.Authority("PERM_DELETE")}}, + {"any role", voter.HasAnyRole("USER", "AUDITOR"), newAuth("bob", "ROLE_USER"), []security.Attribute{security.Role("ADMIN")}}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + assert.False(t, c.v.Supports(c.attrs[0])) + assert.Equal(t, security.DecisionAbstain, c.v.Vote(context.Background(), c.auth, c.attrs)) + assert.ErrorIs(t, decide(security.NewAffirmativeDecisionManager(c.v), c.auth, c.attrs...), security.ErrAccessDenied) + }) + } +} + +// Audit probe TestZZAuditAuthenticatedVoterShortCircuitsRoleCheck: the +// authentication-state voters voted on every attribute, so they granted any +// route under the affirmative strategy. +func TestAuthStateVotersDoNotShortCircuitOtherAttributes(t *testing.T) { + t.Parallel() + + user := newAuth("bob", "ROLE_USER") + + authenticated := security.NewAffirmativeDecisionManager(voter.Authenticated(), voter.HasRole("ADMIN")) + assert.ErrorIs(t, decide(authenticated, user, security.Role("ADMIN")), security.ErrAccessDenied) + + anonymous := security.NewAffirmativeDecisionManager(voter.Anonymous(), voter.HasRole("ADMIN")) + assert.ErrorIs(t, decide(anonymous, newAnonymous(), security.Role("ADMIN")), security.ErrAccessDenied) +} + +func TestAuthStateVotersVoteOnTheirAttribute(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + cases := []struct { + name string + v security.Voter + auth fakeAuth + attr security.Attribute + want security.Decision + }{ + {"authenticated grants a principal", voter.Authenticated(), newAuth("a"), security.IsAuthenticated, security.DecisionGrant}, + {"authenticated denies anonymous", voter.Authenticated(), newAnonymous(), security.IsAuthenticated, security.DecisionDeny}, + {"authenticated abstains on IsAnonymous", voter.Authenticated(), newAuth("a"), security.IsAnonymous, security.DecisionAbstain}, + {"anonymous grants anonymous", voter.Anonymous(), newAnonymous(), security.IsAnonymous, security.DecisionGrant}, + {"anonymous denies a principal", voter.Anonymous(), newAuth("a"), security.IsAnonymous, security.DecisionDeny}, + {"anonymous abstains on IsAuthenticated", voter.Anonymous(), newAnonymous(), security.IsAuthenticated, security.DecisionAbstain}, + {"authenticated abstains on a role", voter.Authenticated(), newAuth("a", "ROLE_X"), security.Role("X"), security.DecisionAbstain}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.want, c.v.Vote(ctx, c.auth, []security.Attribute{c.attr})) + }) + } +} + +func TestAuthStateVotersSupportOnlyTheirAttribute(t *testing.T) { + t.Parallel() + + for name, c := range map[string]struct { + v security.Voter + yes, other security.Attribute + }{ + "authenticated": {voter.Authenticated(), security.IsAuthenticated, security.IsAnonymous}, + "anonymous": {voter.Anonymous(), security.IsAnonymous, security.IsAuthenticated}, + } { + assert.True(t, c.v.Supports(c.yes), name) + assert.False(t, c.v.Supports(c.other), name) + assert.False(t, c.v.Supports(security.Role("X")), name) + assert.False(t, c.v.Supports(security.Scope("y")), name) + assert.False(t, c.v.Supports(security.Authority("z")), name) + } +} + +func TestStateAttributeStrings(t *testing.T) { + t.Parallel() + + assert.Equal(t, "state:authenticated", security.IsAuthenticated.String()) + assert.Equal(t, "state:anonymous", security.IsAnonymous.String()) + assert.NotEqual(t, security.IsAuthenticated, security.IsAnonymous) +} + +// The audit's surviving mutant "IsAuthenticated guard removed": the old +// tests used an anonymous principal without authorities. +func TestStockVotersDenyAnUnauthenticatedValueCarryingTheAuthority(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + cases := []struct { + name string + v security.Voter + auth fakeAuth + attr security.Attribute + }{ + {"role", voter.HasRole("ADMIN"), pending("ROLE_ADMIN"), security.Role("ADMIN")}, + {"scope", voter.HasScope("read"), pending("scope:read"), security.Scope("read")}, + {"authority", voter.HasAuthority("PERM_READ"), pending("PERM_READ"), security.Authority("PERM_READ")}, + {"permission", voter.HasPermission(), pending("ROLE_ADMIN"), security.Permission("always", + func(context.Context, security.Authentication) bool { return true })}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, security.DecisionDeny, c.v.Vote(ctx, c.auth, []security.Attribute{c.attr})) + }) + } +} + +func TestHasAnyRoleGrantsOnlyTheRequestedRoles(t *testing.T) { + t.Parallel() + + v := voter.HasAnyRole("ADMIN", "MANAGER") + adm := security.NewAffirmativeDecisionManager(v) + manager := newAuth("bob", "ROLE_MANAGER") + + // Audit: Example_compose granted a MANAGER on Role("ADMIN"). + assert.ErrorIs(t, decide(adm, manager, security.Role("ADMIN")), security.ErrAccessDenied) + assert.NoError(t, decide(adm, manager, security.Role("ADMIN"), security.Role("MANAGER"))) + assert.NoError(t, decide(adm, manager, security.Role("MANAGER"))) +} + +// Review finding 4: under the unanimous strategy every voter must grant +// (AND across voters) but a multi-name voter grants when the principal +// holds any of the requested names (OR within one voter, like Spring's +// RoleVoter) — express an AND of roles with one voter per role. +func TestUnanimousStrategySemantics(t *testing.T) { + t.Parallel() + + auditor := newAuth("ann", "ROLE_AUDITOR") + attrs := []security.Attribute{security.Role("ADMIN"), security.Role("AUDITOR")} + + anyOf := security.NewUnanimousDecisionManager([]security.Voter{voter.HasAnyRole("ADMIN", "AUDITOR")}) + assert.NoError(t, decide(anyOf, auditor, attrs...), "OR within one voter") + + allOf := security.NewUnanimousDecisionManager([]security.Voter{voter.HasRole("ADMIN"), voter.HasRole("AUDITOR")}) + assert.ErrorIs(t, decide(allOf, auditor, attrs...), security.ErrAccessDenied, "AND across voters") +} + +// Review finding 2 / user decision U2: an attribute no voter supports used +// to be ignored, which widened access under unanimous and consensus. +func TestDecideDeniesAnAttributeNoVoterSupports(t *testing.T) { + t.Parallel() + + admin := newAuth("root", "ROLE_ADMIN") + + unanimous := security.NewUnanimousDecisionManager([]security.Voter{voter.HasRole("ADMIN"), voter.HasScope("billing:read")}) + assert.ErrorIs(t, decide(unanimous, admin, security.Role("ADMIN"), security.Scope("billing:write")), security.ErrAccessDenied) + + consensus := security.NewConsensusDecisionManager([]security.Voter{voter.HasRole("ADMIN"), voter.HasRole("USER"), voter.HasScope("read")}) + assert.ErrorIs(t, decide(consensus, admin, security.Role("ADMIN"), security.Scope("write")), security.ErrAccessDenied) + + affirmative := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) + assert.ErrorIs(t, decide(affirmative, admin, security.Role("ADMIN"), security.Role("SUPPORT")), security.ErrAccessDenied) + + fallback := security.NewUnanimousDecisionManager([]security.Voter{voter.HasRole("ADMIN")}, + security.WithAbstainFallback(security.DecisionGrant)) + assert.ErrorIs(t, decide(fallback, admin, security.Scope("anything")), security.ErrAccessDenied) +} + +// Review finding 1 / user decision U1: an inner voter that abstains — its +// attribute is not listed — must not let an And grant. +func TestAndRequiresEveryInnerVoterToGrant(t *testing.T) { + t.Parallel() + + notBanned := voter.And(voter.HasRole("USER"), voter.Not(voter.HasRole("BANNED"))) + adm := security.NewAffirmativeDecisionManager(notBanned) + banned := newAuth("eve", "ROLE_USER", "ROLE_BANNED") + user := newAuth("bob", "ROLE_USER") + + assert.ErrorIs(t, decide(adm, banned, security.Role("USER")), security.ErrAccessDenied, + "the BANNED requirement is not listed: the And denies instead of ignoring it") + assert.ErrorIs(t, decide(adm, user, security.Role("USER")), security.ErrAccessDenied) + assert.ErrorIs(t, decide(adm, banned, security.Role("USER"), security.Role("BANNED")), security.ErrAccessDenied) + assert.NoError(t, decide(adm, user, security.Role("USER"), security.Role("BANNED"))) + + scoped := security.NewAffirmativeDecisionManager(voter.And(voter.HasRole("ADMIN"), voter.HasScope("billing:write"))) + admin := newAuth("root", "ROLE_ADMIN") + assert.ErrorIs(t, decide(scoped, admin, security.Role("ADMIN")), security.ErrAccessDenied) + assert.ErrorIs(t, decide(scoped, admin, security.Role("ADMIN"), security.Scope("billing:write")), security.ErrAccessDenied) + assert.NoError(t, decide(scoped, newAuth("root", "ROLE_ADMIN", "scope:billing:write"), + security.Role("ADMIN"), security.Scope("billing:write"))) + + nested := security.NewAffirmativeDecisionManager(voter.And( + voter.Or(voter.HasRole("ADMIN"), voter.HasRole("OWNER")), + voter.Not(voter.HasRole("BANNED")), + )) + owner := newAuth("olga", "ROLE_OWNER") + assert.ErrorIs(t, decide(nested, owner, security.Role("OWNER")), security.ErrAccessDenied) + assert.NoError(t, decide(nested, owner, security.Role("OWNER"), security.Role("BANNED"))) +} + +// Audit probe TestZZAuditNotGrantsAnonymous / decision 7. +func TestNotNeverGrantsAnUnauthenticatedPrincipal(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + banned := security.NewAffirmativeDecisionManager(voter.Not(voter.HasRole("BANNED"))) + assert.ErrorIs(t, decide(banned, newAnonymous(), security.Role("BANNED")), security.ErrAccessDenied) + assert.NoError(t, decide(banned, newAuth("bob", "ROLE_USER"), security.Role("BANNED")), "no over-correction") + + permission := security.NewAffirmativeDecisionManager(voter.Not(voter.HasPermission())) + owner := security.Permission("owner", func(context.Context, security.Authentication) bool { return true }) + assert.ErrorIs(t, decide(permission, newAnonymous(), owner), security.ErrAccessDenied) + + notAuthenticated := security.NewAffirmativeDecisionManager(voter.Not(voter.Authenticated())) + assert.ErrorIs(t, decide(notAuthenticated, newAnonymous(), security.IsAuthenticated), security.ErrAccessDenied, + "use Anonymous() with IsAnonymous to admit anonymous callers") + + for _, c := range []struct { + in security.Decision + authed bool + expected security.Decision + }{ + {security.DecisionGrant, true, security.DecisionDeny}, + {security.DecisionGrant, false, security.DecisionDeny}, + {security.DecisionDeny, true, security.DecisionGrant}, + {security.DecisionDeny, false, security.DecisionDeny}, + {security.DecisionAbstain, true, security.DecisionAbstain}, + {security.DecisionAbstain, false, security.DecisionAbstain}, + {security.Decision(42), true, security.DecisionDeny}, + {security.Decision(42), false, security.DecisionDeny}, + } { + auth := newAnonymous() + if c.authed { + auth = newAuth("a") + } + + assert.Equal(t, c.expected, voter.Not(constVoter{d: c.in}).Vote(ctx, auth, nil), "in=%v authenticated=%v", c.in, c.authed) + } +} + +func TestCompositesRefuseNilOrMissingVoters(t *testing.T) { + t.Parallel() + + ok := voter.HasRole("X") + + assert.PanicsWithValue(t, "voter: And: no voter", func() { voter.And() }) + assert.PanicsWithValue(t, "voter: Or: no voter", func() { voter.Or() }) + assert.PanicsWithValue(t, "voter: And: nil voter at index 1", func() { voter.And(ok, nil) }) + assert.PanicsWithValue(t, "voter: Or: nil voter at index 0", func() { voter.Or(nil, ok) }) + assert.PanicsWithValue(t, "voter: Not: nil voter at index 0", func() { voter.Not(nil) }) +} From 1b391f6a29bb354f3400617795f92f67ddb2795d Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 00:15:59 +0200 Subject: [PATCH 05/29] fix(voter): strict role and scope authority naming Roles, scopes and plain authorities shared one flat namespace: HasRole accepted a bare authority as well as its ROLE_ form, and HasScope a bare authority, its scope: form and any scope: authority holding the name among space-separated words. One bare "admin" authority (a scope a client obtained, a fine-grained permission) therefore satisfied the role, the scope and the authority checks at once, and HasRole("") matched "ROLE_". HasRole now matches only security.RolePrefix + name ("ROLE_ADMIN") and HasScope only one security.ScopePrefix + name authority per scope ("scope:read:mail", what jwtsec.DefaultAuthorityResolver emits); HasAuthority keeps its verbatim match. The constructors panic on a name they could never match: none, an empty one, a role already starting with ROLE_, a scope that is not an RFC 6749 section 3.3 scope-token or starts with scope:. The basic-http example emits ROLE_ authorities and its loader no longer echoes the typed username. Audit: CORE-I1, CORE-07. --- CHANGELOG.md | 10 ++++ CLAUDE.md | 4 +- MIGRATION.md | 33 +++++++++++ attribute.go | 29 +++++---- authentication.go | 2 + docs/security-considerations.md | 5 ++ examples/basic-http/main.go | 12 ++-- voter/authority.go | 17 ++++-- voter/names.go | 82 ++++++++++++++++++++++++++ voter/naming_test.go | 101 ++++++++++++++++++++++++++++++++ voter/role.go | 38 ++++++------ voter/role_test.go | 14 ++--- voter/scope.go | 56 +++++++----------- voter/scope_test.go | 18 +++--- 14 files changed, 332 insertions(+), 89 deletions(-) create mode 100644 voter/names.go create mode 100644 voter/naming_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cf0a77d0..37b077d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -446,6 +446,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - `security.StateAttribute` with `security.IsAuthenticated` and `security.IsAnonymous`: the attributes `voter.Authenticated()` and `voter.Anonymous()` vote on. +- `security.RolePrefix` (`ROLE_`) and `security.ScopePrefix` (`scope:`): the + authority namespaces the voter package matches roles and scopes in. ### Changed @@ -635,6 +637,14 @@ legacy packages (`authentication/`, `authorization/`, the in-tree supports and pass each voter only the attributes it supports. `And`, `Or` and `Not` panic on a nil or missing voter. See [MIGRATION.md](MIGRATION.md). +- Roles and scopes live in their own authority namespaces: `voter.HasRole` + matches only `ROLE_` and `voter.HasScope` only one `scope:` + per authority. A bare `admin` authority no longer satisfies + `HasRole("admin")` and `HasScope("admin")`, and `scope:a b` is no longer + split into two scopes. The voter constructors panic on a name they could + never match: none, an empty one, a role starting with `ROLE_`, a scope + that is not an RFC 6749 §3.3 scope-token or starts with `scope:`. See + [MIGRATION.md](MIGRATION.md). - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); diff --git a/CLAUDE.md b/CLAUDE.md index b6ab04a5..7bd2240f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,7 +211,9 @@ Carrier ──> Extractor ──> Authentication ──> Manager/Authenticator Security's). The attributes a route lists are the policy: each voter votes only on the attributes it supports — family AND value (`HasRole("ADMIN")` abstains on `Role("USER")`) — and the ADM denies when an attribute is - supported by no voter. Stock voters live in `voter/` (`HasRole`, + supported by no voter. Roles are matched only as `ROLE_` authorities + and scopes only as one `scope:` authority each + (`security.RolePrefix` / `security.ScopePrefix`). Stock voters live in `voter/` (`HasRole`, `HasAnyRole`, `HasScope`, `HasAnyScope`, `HasAuthority`, `HasAnyAuthority`, `HasPermission`, and `Authenticated` / `Anonymous`, which vote only on `security.IsAuthenticated` / `security.IsAnonymous`); diff --git a/MIGRATION.md b/MIGRATION.md index cc67a612..51acee11 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1349,3 +1349,36 @@ To migrate: 6. Custom voters: `Supports` must check the attribute's value, not only its type, and `Vote` must consider only the attributes it supports (the decision manager now passes it only those) and abstain without them. + +### `voter`: roles and scopes live in their own authority namespaces + +`voter.HasRole("admin")` accepted the authority `admin` as well as +`ROLE_admin`, and `voter.HasScope("admin")` accepted `admin`, `scope:admin` +and any `scope:…` authority holding `admin` among space-separated words: one +bare `admin` authority — a scope a client obtained, a fine-grained +permission — satisfied the role, the scope and the authority checks at once, +and one authority string could carry many scopes. `HasRole("")` matched +`ROLE_`. + +| Authority held | Check | Before | After | +| --- | --- | --- | --- | +| `ROLE_admin` | `HasRole("admin")` | granted | granted | +| `admin` | `HasRole("admin")` | granted | denied | +| `scope:admin` | `HasScope("admin")` | granted | granted | +| `admin` | `HasScope("admin")` | granted | denied | +| `scope:read billing:admin` | `HasScope("billing:admin")` | granted | denied | +| `admin` | `HasAuthority("admin")` | granted | granted (unchanged) | +| — | `HasRole("")`, `HasRole("ROLE_X")`, `HasScope("a b")`, `HasScope("scope:x")`, `HasAnyRole()` | constructed | panic at construction | + +To migrate: + +1. Emit roles as `security.RolePrefix + name` (`ROLE_ADMIN`) from your + `basic.WithAuthorityResolver` and `session.PrincipalLoader` + implementations — `examples/basic-http` shows it. +2. Emit one `security.ScopePrefix + scope` authority per scope + (`scope:read:mail`): `jwtsec.DefaultAuthorityResolver` already does; split + a space-separated `scope` claim before prefixing it. +3. Keep `HasAuthority` for verbatim, unprefixed authorities. +4. Pass the bare name to `HasRole`/`security.Role` (`"ADMIN"`, not + `"ROLE_ADMIN"`), and valid RFC 6749 §3.3 scope-tokens to + `HasScope`/`security.Scope`. diff --git a/attribute.go b/attribute.go index 78abdcd2..e5d679f7 100644 --- a/attribute.go +++ b/attribute.go @@ -20,15 +20,26 @@ type Attribute interface { String() string } -// RoleAttribute names a role expected on the authenticated principal. Roles -// use the Spring Security "ROLE_" prefix at the wire level (in OTel -// attributes and in custom Authorities() slices) but the constructor -// accepts the bare name to keep usage idiomatic. +// Authority prefixes. A principal holds a role as the authority +// RolePrefix + name ("ROLE_ADMIN", the Spring Security convention) and a +// scope as the authority ScopePrefix + name ("scope:read:mail", one scope +// per authority — what jwtsec.DefaultAuthorityResolver emits). The voter +// package matches roles and scopes in these namespaces only: a bare +// "admin" authority is neither a role nor a scope. +const ( + RolePrefix = "ROLE_" + ScopePrefix = "scope:" +) + +// RoleAttribute names a role expected on the authenticated principal: the +// bare name, matched against the authority RolePrefix + name. A name that +// is empty or already starts with RolePrefix cannot be required (no stock +// voter accepts it, so the decision is denied). type RoleAttribute string // String implements [Attribute]. Output is "ROLE_" — Spring-compatible // for ops tooling that already keys off that convention. -func (r RoleAttribute) String() string { return rolePrefix + string(r) } +func (r RoleAttribute) String() string { return RolePrefix + string(r) } // Name returns the bare role name (without the ROLE_ prefix). func (r RoleAttribute) Name() string { return string(r) } @@ -36,15 +47,13 @@ func (r RoleAttribute) Name() string { return string(r) } // Role constructs a [RoleAttribute] from a bare role name. func Role(name string) Attribute { return RoleAttribute(name) } -const rolePrefix = "ROLE_" - // ScopeAttribute names an OAuth2 scope expected on the authenticated -// principal. Scope names follow the RFC 6749 §3.3 grammar but this type -// stays format-agnostic. +// principal: an RFC 6749 §3.3 scope-token, matched against the authority +// ScopePrefix + name. type ScopeAttribute string // String implements [Attribute]. Output is "scope:". -func (s ScopeAttribute) String() string { return "scope:" + string(s) } +func (s ScopeAttribute) String() string { return ScopePrefix + string(s) } // Name returns the bare scope name. func (s ScopeAttribute) Name() string { return string(s) } diff --git a/authentication.go b/authentication.go index 8457e021..2e836d4d 100644 --- a/authentication.go +++ b/authentication.go @@ -41,6 +41,8 @@ type Authentication interface { // system has granted to this principal. Implementations MUST NOT expose // their internal state through it — return a copy (slices.Clone) — so // that no caller can rewrite the authorities of an immutable snapshot. + // Roles are carried as RolePrefix + name and scopes as ScopePrefix + + // name, one scope per authority, for the voter package to match them. Authorities() []string // IsAuthenticated reports whether the credentials have been validated by diff --git a/docs/security-considerations.md b/docs/security-considerations.md index 8aa0bed6..29d02f6c 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1102,6 +1102,11 @@ Authorization follows Spring Security's voter model, and fails closed: asks for. `voter.Authenticated()` and `voter.Anonymous()` vote only on `security.IsAuthenticated` / `security.IsAnonymous`: list the state you require among the route's attributes. +- **Separate namespaces.** A role is the authority `ROLE_` + (`security.RolePrefix`), a scope the authority `scope:` + (`security.ScopePrefix`, one scope per authority), anything else a plain + authority matched verbatim by `HasAuthority`: a scope a client obtained + can never pass for a role. - **No requirement is ignored.** A decision is denied when one of its attributes is supported by no voter of the decision manager, when there is no attribute, and when there is no voter. diff --git a/examples/basic-http/main.go b/examples/basic-http/main.go index b82e384e..f1344211 100644 --- a/examples/basic-http/main.go +++ b/examples/basic-http/main.go @@ -63,11 +63,13 @@ type loader struct{ users map[string]user } // LoadByUsername implements [basic.UserLoader]. An unknown user yields an // error wrapping [security.ErrInvalidCredentials] so the response is -// indistinguishable from a wrong password (anti-enumeration). +// indistinguishable from a wrong password (anti-enumeration). The error does +// not echo the typed username: it may be a password typed in the wrong +// field. func (l loader) LoadByUsername(_ context.Context, username string) (basic.PasswordUser, error) { u, ok := l.users[username] if !ok { - return nil, fmt.Errorf("unknown user %q: %w", username, security.ErrInvalidCredentials) + return nil, fmt.Errorf("unknown user: %w", security.ErrInvalidCredentials) } return u, nil @@ -90,9 +92,11 @@ func newServer() (http.Handler, error) { return nil, fmt.Errorf("hash root: %w", err) } + // Roles are carried as ROLE_ authorities (security.RolePrefix): + // that is what voter.HasRole matches. store := loader{users: map[string]user{ - "alice": {subject: "alice", hash: aliceHash, roles: []string{"USER"}}, //nolint:goconst // example code, readability over deduplication - "root": {subject: "root", hash: rootHash, roles: []string{"USER", "ADMIN"}}, //nolint:goconst // example code, readability over deduplication + "alice": {subject: "alice", hash: aliceHash, roles: []string{"ROLE_USER"}}, //nolint:goconst // example code, readability over deduplication + "root": {subject: "root", hash: rootHash, roles: []string{"ROLE_USER", "ROLE_ADMIN"}}, //nolint:goconst // example code, readability over deduplication }} authenticator := basic.NewAuthenticator(store, hasher, diff --git a/voter/authority.go b/voter/authority.go index 198a850c..b7cf305e 100644 --- a/voter/authority.go +++ b/voter/authority.go @@ -14,20 +14,25 @@ import ( // the [security.AuthorityAttribute] naming that authority and abstains on // every other attribute. It grants when the authenticated principal carries // the authority verbatim — no prefix normalisation (use [HasRole] / -// [HasScope] when you want the conventions of those types). +// [HasScope] when you want the conventions of those types). It panics if +// name is empty. func HasAuthority(name string) security.Voter { - return newAuthorityVoter(name) + return newAuthorityVoter("HasAuthority", name) } // HasAnyAuthority returns a [security.Voter] bound to several authorities: it // grants when the principal carries at least one of the authorities the -// route requests. +// route requests. It panics without an authority or on an empty one. func HasAnyAuthority(names ...string) security.Voter { - return newAuthorityVoter(names...) + return newAuthorityVoter("HasAnyAuthority", names...) } -func newAuthorityVoter(names ...string) security.Voter { - return nameVoter{names: slices.Clone(names), nameOf: authorityName, holds: slices.Contains[[]string]} +func newAuthorityVoter(constructor string, names ...string) security.Voter { + return nameVoter{ + names: mustNames(constructor, "authority", names, checkAuthority), + nameOf: authorityName, + holds: slices.Contains[[]string], + } } // authorityName returns the name of an authority attribute. diff --git a/voter/names.go b/voter/names.go new file mode 100644 index 00000000..50c998aa --- /dev/null +++ b/voter/names.go @@ -0,0 +1,82 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package voter + +import ( + "fmt" + "slices" + "strings" +) + +// mustNames returns a copy of names after checking that the voter can match +// every one of them: a voter bound to no name, or to a name no authority +// can satisfy, is a programming error reported at construction rather than +// a requirement silently impossible to meet. +func mustNames(constructor, kind string, names []string, check func(string) error) []string { + if len(names) == 0 { + panic(fmt.Sprintf("voter: %s: no %s", constructor, kind)) + } + + for _, n := range names { + if err := check(n); err != nil { + panic(fmt.Sprintf("voter: %s: %v", constructor, err)) + } + } + + return slices.Clone(names) +} + +// checkRole accepts a non-empty bare role name. +func checkRole(name string) error { + switch { + case name == "": + return errEmptyName("role") + case strings.HasPrefix(name, rolePrefix): + return fmt.Errorf("role %q must not start with %q", name, rolePrefix) + default: + return nil + } +} + +// checkScope accepts an RFC 6749 §3.3 scope-token — 1*( %x21 / %x23-5B / +// %x5D-7E ) — that does not already carry the scope prefix. +func checkScope(name string) error { + if !isScopeToken(name) { + return fmt.Errorf("scope %q is not an RFC 6749 scope-token", name) + } + + if strings.HasPrefix(name, scopePrefix) { + return fmt.Errorf("scope %q must not start with %q", name, scopePrefix) + } + + return nil +} + +// checkAuthority accepts any non-empty authority. +func checkAuthority(name string) error { + if name == "" { + return errEmptyName("authority") + } + + return nil +} + +func errEmptyName(kind string) error { return fmt.Errorf("empty %s", kind) } + +// isScopeToken reports whether s is an RFC 6749 §3.3 scope-token. +func isScopeToken(s string) bool { + if s == "" { + return false + } + + for i := range len(s) { + c := s[i] + if c < 0x21 || c > 0x7e || c == '"' || c == '\\' { + return false + } + } + + return true +} diff --git a/voter/naming_test.go b/voter/naming_test.go new file mode 100644 index 00000000..5c557cd4 --- /dev/null +++ b/voter/naming_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package voter_test + +import ( + "context" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/voter" + "github.com/stretchr/testify/assert" +) + +func TestAuthorityPrefixes(t *testing.T) { + t.Parallel() + + assert.Equal(t, "ROLE_", security.RolePrefix) + assert.Equal(t, "scope:", security.ScopePrefix) + assert.Equal(t, security.RolePrefix+"ADMIN", security.Role("ADMIN").String()) + assert.Equal(t, security.ScopePrefix+"read:mail", security.Scope("read:mail").String()) +} + +// Audit probe TestZZAuditFlatNamespace: a bare "admin" authority satisfied +// HasRole("admin") and HasScope("admin"), and a "scope:a b" authority was +// split on spaces. +func TestRolesAndScopesUseTheirOwnNamespace(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + cases := []struct { + name string + v security.Voter + auth fakeAuth + attr security.Attribute + want security.Decision + }{ + {"bare authority is not a role", voter.HasRole("admin"), newAuth("svc", "admin"), security.Role("admin"), security.DecisionDeny}, + {"bare authority is not a scope", voter.HasScope("admin"), newAuth("svc", "admin"), security.Scope("admin"), security.DecisionDeny}, + {"scope is not a role", voter.HasRole("admin"), newAuth("svc", "scope:admin"), security.Role("admin"), security.DecisionDeny}, + {"role is not a scope", voter.HasScope("admin"), newAuth("svc", "ROLE_admin"), security.Scope("admin"), security.DecisionDeny}, + {"space-packed scopes are one authority", voter.HasScope("billing:admin"), + newAuth("u", "scope:payments:write billing:admin"), security.Scope("billing:admin"), security.DecisionDeny}, + {"prefixed role", voter.HasRole("admin"), newAuth("u", "ROLE_admin"), security.Role("admin"), security.DecisionGrant}, + {"prefixed scope", voter.HasScope("admin"), newAuth("u", "scope:admin"), security.Scope("admin"), security.DecisionGrant}, + {"authority verbatim", voter.HasAuthority("admin"), newAuth("u", "admin"), security.Authority("admin"), security.DecisionGrant}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.want, c.v.Vote(ctx, c.auth, []security.Attribute{c.attr})) + }) + } +} + +func TestVoterConstructorsRefuseUnmatchableNames(t *testing.T) { + t.Parallel() + + cases := []struct { + want string + fn func() + }{ + {"voter: HasAnyRole: no role", func() { voter.HasAnyRole() }}, + {"voter: HasRole: empty role", func() { voter.HasRole("") }}, + {`voter: HasRole: role "ROLE_ADMIN" must not start with "ROLE_"`, func() { voter.HasRole("ROLE_ADMIN") }}, + {`voter: HasAnyRole: role "ROLE_X" must not start with "ROLE_"`, func() { voter.HasAnyRole("A", "ROLE_X") }}, + {"voter: HasAnyScope: no scope", func() { voter.HasAnyScope() }}, + {`voter: HasScope: scope "" is not an RFC 6749 scope-token`, func() { voter.HasScope("") }}, + {`voter: HasScope: scope "a b" is not an RFC 6749 scope-token`, func() { voter.HasScope("a b") }}, + {`voter: HasScope: scope "a\"b" is not an RFC 6749 scope-token`, func() { voter.HasScope(`a"b`) }}, + {`voter: HasScope: scope "a\\b" is not an RFC 6749 scope-token`, func() { voter.HasScope(`a\b`) }}, + {`voter: HasScope: scope "café" is not an RFC 6749 scope-token`, func() { voter.HasScope("café") }}, + {`voter: HasAnyScope: scope "scope:read" must not start with "scope:"`, func() { voter.HasAnyScope("scope:read") }}, + {"voter: HasScope: scope \"a\\x7fb\" is not an RFC 6749 scope-token", func() { voter.HasScope("a\x7fb") }}, + {"voter: HasAnyAuthority: no authority", func() { voter.HasAnyAuthority() }}, + {"voter: HasAuthority: empty authority", func() { voter.HasAuthority("") }}, + } + + for _, c := range cases { + assert.PanicsWithValue(t, c.want, c.fn) + } + + assert.NotPanics(t, func() { voter.HasScope("read:mail") }) + assert.NotPanics(t, func() { voter.HasScope("!#$%&'()*+,-./:;<=>?@[]^_`{|}~") }) +} + +// Attributes no voter can be bound to are unsatisfiable: denied. +func TestUnmatchableAttributesAreDenied(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN"), voter.HasScope("read")) + root := newAuth("root", "ROLE_ADMIN", "ROLE_ROLE_ADMIN", "ROLE_", "scope:read") + + for _, attr := range []security.Attribute{security.Role("ROLE_ADMIN"), security.Role(""), security.Scope("")} { + assert.ErrorIs(t, decide(adm, root, attr), security.ErrAccessDenied, attr.String()) + } +} diff --git a/voter/role.go b/voter/role.go index 9f16090b..22b121e3 100644 --- a/voter/role.go +++ b/voter/role.go @@ -12,27 +12,32 @@ import ( // HasRole returns a [security.Voter] bound to one role: it votes on the // [security.RoleAttribute] naming that role and abstains on every other -// attribute. It grants when the authenticated principal carries the role -// (matched on Authorities() with the Spring-style ROLE_ prefix) and denies -// otherwise; unauthenticated requests always vote Deny. +// attribute. It grants when the authenticated principal carries the +// authority security.RolePrefix + role ("ROLE_ADMIN") and denies otherwise; +// unauthenticated requests always vote Deny. A bare "ADMIN" authority is not +// a role. // -// The voter compares against [security.Authentication.Authorities] in two -// shapes: with and without the ROLE_ prefix, so applications can use either -// convention in their user store. +// HasRole panics if role is empty or already starts with "ROLE_" (such a +// role could never match). func HasRole(role string) security.Voter { - return newRoleVoter(role) + return newRoleVoter("HasRole", role) } // HasAnyRole returns a [security.Voter] bound to several roles: it votes on // the role attributes naming one of them and grants when the principal // carries at least one of the roles the route requests (OR, like Spring's -// RoleVoter). Same comparison rules as [HasRole]. +// RoleVoter). Same comparison rules as [HasRole]; it panics without a role +// or on a role HasRole refuses. func HasAnyRole(roles ...string) security.Voter { - return newRoleVoter(roles...) + return newRoleVoter("HasAnyRole", roles...) } -func newRoleVoter(roles ...string) security.Voter { - return nameVoter{names: slices.Clone(roles), nameOf: roleName, holds: hasRole} +func newRoleVoter(constructor string, roles ...string) security.Voter { + return nameVoter{ + names: mustNames(constructor, "role", roles, checkRole), + nameOf: roleName, + holds: hasRole, + } } // roleName returns the bare name of a role attribute. @@ -42,12 +47,9 @@ func roleName(a security.Attribute) (string, bool) { return r.Name(), ok } -// hasRole reports whether authorities contains role either verbatim or with -// the Spring-style ROLE_ prefix. +// hasRole reports whether authorities contains the role authority. func hasRole(authorities []string, role string) bool { - if slices.Contains(authorities, role) { - return true - } - - return slices.Contains(authorities, "ROLE_"+role) + return slices.Contains(authorities, rolePrefix+role) } + +const rolePrefix = security.RolePrefix diff --git a/voter/role_test.go b/voter/role_test.go index ea3b2223..b28c85ca 100644 --- a/voter/role_test.go +++ b/voter/role_test.go @@ -23,18 +23,18 @@ func TestHasRoleSupportsOnlyRoleAttribute(t *testing.T) { assert.False(t, v.Supports(security.Scope("ADMIN"))) } -func TestHasRoleMatchesEitherPrefixedOrBare(t *testing.T) { +func TestHasRoleMatchesThePrefixedRoleOnly(t *testing.T) { t.Parallel() v := voter.HasRole("ADMIN") attrs := []security.Attribute{security.Role("ADMIN")} - assert.Equal(t, security.DecisionGrant, - v.Vote(context.Background(), newAuth("a", "ADMIN"), attrs)) assert.Equal(t, security.DecisionGrant, v.Vote(context.Background(), newAuth("a", "ROLE_ADMIN"), attrs)) assert.Equal(t, security.DecisionDeny, - v.Vote(context.Background(), newAuth("a", "USER"), attrs)) + v.Vote(context.Background(), newAuth("a", "ADMIN"), attrs), "a bare authority is not a role") + assert.Equal(t, security.DecisionDeny, + v.Vote(context.Background(), newAuth("a", "ROLE_USER"), attrs)) } func TestHasRoleDeniesUnauthenticated(t *testing.T) { @@ -57,9 +57,9 @@ func TestHasAnyRoleMatchesAtLeastOneRequestedRole(t *testing.T) { want security.Decision }{ {"first_matches", newAuth("a", "ROLE_ADMIN"), []security.Attribute{security.Role("ADMIN")}, security.DecisionGrant}, - {"second_matches", newAuth("a", "OWNER"), []security.Attribute{security.Role("ADMIN"), security.Role("OWNER")}, security.DecisionGrant}, - {"held_but_not_requested", newAuth("a", "OWNER"), []security.Attribute{security.Role("ADMIN")}, security.DecisionDeny}, - {"neither", newAuth("a", "USER"), []security.Attribute{security.Role("ADMIN"), security.Role("OWNER")}, security.DecisionDeny}, + {"second_matches", newAuth("a", "ROLE_OWNER"), []security.Attribute{security.Role("ADMIN"), security.Role("OWNER")}, security.DecisionGrant}, + {"held_but_not_requested", newAuth("a", "ROLE_OWNER"), []security.Attribute{security.Role("ADMIN")}, security.DecisionDeny}, + {"neither", newAuth("a", "ROLE_USER"), []security.Attribute{security.Role("ADMIN"), security.Role("OWNER")}, security.DecisionDeny}, {"not_bound", newAuth("a", "ROLE_USER"), []security.Attribute{security.Role("USER")}, security.DecisionAbstain}, } for _, c := range cases { diff --git a/voter/scope.go b/voter/scope.go index b8f846ad..9ed3b508 100644 --- a/voter/scope.go +++ b/voter/scope.go @@ -6,33 +6,39 @@ package voter import ( "slices" - "strings" "github.com/hyperscale-stack/security" ) // HasScope returns a [security.Voter] bound to one OAuth2 scope: it votes on // the [security.ScopeAttribute] naming that scope and abstains on every other -// attribute. It grants when the authenticated principal carries the scope -// and denies otherwise; unauthenticated requests always vote Deny. Scope -// matching is exact and supports two storage conventions on -// [security.Authentication.Authorities]: +// attribute. It grants when the authenticated principal carries the +// authority security.ScopePrefix + scope ("scope:read:mail" — one scope per +// authority, as jwtsec.DefaultAuthorityResolver emits) and denies otherwise; +// unauthenticated requests always vote Deny. A bare "read:mail" authority is +// not a scope, and "scope:a b" is the single scope "a b". // -// - bare scope name ("read:mail") -// - "scope:" prefix ("scope:read:mail") +// HasScope panics if scope is not an RFC 6749 §3.3 scope-token (empty, with +// a space, a double quote, a backslash or a non-ASCII character) or already +// starts with "scope:" — such a scope could never match. func HasScope(scope string) security.Voter { - return newScopeVoter(scope) + return newScopeVoter("HasScope", scope) } // HasAnyScope returns a [security.Voter] bound to several scopes: it grants // when the principal carries at least one of the scopes the route requests. -// Same comparison rules as [HasScope]. +// Same comparison rules as [HasScope]; it panics without a scope or on a +// scope HasScope refuses. func HasAnyScope(scopes ...string) security.Voter { - return newScopeVoter(scopes...) + return newScopeVoter("HasAnyScope", scopes...) } -func newScopeVoter(scopes ...string) security.Voter { - return nameVoter{names: slices.Clone(scopes), nameOf: scopeName, holds: hasScope} +func newScopeVoter(constructor string, scopes ...string) security.Voter { + return nameVoter{ + names: mustNames(constructor, "scope", scopes, checkScope), + nameOf: scopeName, + holds: hasScope, + } } // scopeName returns the name of a scope attribute. @@ -42,27 +48,9 @@ func scopeName(a security.Attribute) (string, bool) { return s.Name(), ok } +// hasScope reports whether authorities contains the scope authority. func hasScope(authorities []string, scope string) bool { - if slices.Contains(authorities, scope) { - return true - } - - prefixed := "scope:" + scope - - for _, a := range authorities { - if a == prefixed { - return true - } - // Also accept the OAuth2 "scope" claim packaged as a - // space-separated string in a single authority. - if strings.HasPrefix(a, "scope:") { - for _, s := range strings.Split(a[len("scope:"):], " ") { - if s == scope { - return true - } - } - } - } - - return false + return slices.Contains(authorities, scopePrefix+scope) } + +const scopePrefix = security.ScopePrefix diff --git a/voter/scope_test.go b/voter/scope_test.go index 8d0946df..58d9ef53 100644 --- a/voter/scope_test.go +++ b/voter/scope_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestHasScopeMatchesStorageConventions(t *testing.T) { +func TestHasScopeMatchesOnePrefixedScopePerAuthority(t *testing.T) { t.Parallel() v := voter.HasScope("read:mail") @@ -24,10 +24,10 @@ func TestHasScopeMatchesStorageConventions(t *testing.T) { auth fakeAuth want security.Decision }{ - {"bare_match", newAuth("a", "read:mail"), security.DecisionGrant}, {"prefixed_match", newAuth("a", "scope:read:mail"), security.DecisionGrant}, - {"space_packed_match", newAuth("a", "scope:foo read:mail write:mail"), security.DecisionGrant}, - {"miss", newAuth("a", "write:mail"), security.DecisionDeny}, + {"bare_is_not_a_scope", newAuth("a", "read:mail"), security.DecisionDeny}, + {"space_packed_is_one_authority", newAuth("a", "scope:foo read:mail write:mail"), security.DecisionDeny}, + {"miss", newAuth("a", "scope:write:mail"), security.DecisionDeny}, {"unauthenticated", newAnonymous(), security.DecisionDeny}, } for _, c := range cases { @@ -59,11 +59,11 @@ func TestHasAnyScopeMatchesAtLeastOneRequestedScope(t *testing.T) { attrs []security.Attribute want security.Decision }{ - {"first", newAuth("a", "read"), []security.Attribute{security.Scope("read")}, security.DecisionGrant}, - {"second", newAuth("a", "write"), []security.Attribute{security.Scope("read"), security.Scope("write")}, security.DecisionGrant}, - {"held_but_not_requested", newAuth("a", "write"), []security.Attribute{security.Scope("read")}, security.DecisionDeny}, - {"none", newAuth("a", "admin"), []security.Attribute{security.Scope("read")}, security.DecisionDeny}, - {"not_bound", newAuth("a", "admin"), []security.Attribute{security.Scope("admin")}, security.DecisionAbstain}, + {"first", newAuth("a", "scope:read"), []security.Attribute{security.Scope("read")}, security.DecisionGrant}, + {"second", newAuth("a", "scope:write"), []security.Attribute{security.Scope("read"), security.Scope("write")}, security.DecisionGrant}, + {"held_but_not_requested", newAuth("a", "scope:write"), []security.Attribute{security.Scope("read")}, security.DecisionDeny}, + {"none", newAuth("a", "scope:admin"), []security.Attribute{security.Scope("read")}, security.DecisionDeny}, + {"not_bound", newAuth("a", "scope:admin"), []security.Attribute{security.Scope("admin")}, security.DecisionAbstain}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { From 9380f8f70b6e571b2e6b1b37eea0f2f12ca14c66 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 00:20:46 +0200 Subject: [PATCH 06/29] fix(http): read credentials from header fields, the session from its cookie httpsec.Carrier answered Get and Values from a cookie, then from the query string, named like the key whenever the header was absent. Basic and Bearer credentials were therefore accepted from ?Authorization=... and from a cookie named Authorization, leaking through access logs, Referer and history (RFC 6750 section 5.3, CWE-598). The session manager read its cookie through the same Get, so a session was accepted from ?session=... and from a Session request header that even took precedence over the victim's cookie: a link or a cross-site form logged the victim into the attacker's session (CWE-352, CWE-384). Carrier.Get and Values now read the request's header fields only, and never touch the URL (a request without URL no longer panics). The session manager reads its cookie through a new explicit accessor, httpsec.Carrier.CookiesNamed, declared on its side as session.CookieReader; a carrier without it carries no session. The session-web, basic-http and bearer-jwt examples pin the behaviour end to end. Audit: CORE-02, CORE-I3. --- CHANGELOG.md | 15 +++ CLAUDE.md | 5 +- LIMITATIONS.md | 4 + MIGRATION.md | 30 ++++++ carrier.go | 13 ++- docs/architecture.md | 8 +- docs/security-considerations.md | 13 +++ .../basic-http/credentials_location_test.go | 56 ++++++++++++ .../bearer-jwt/credentials_location_test.go | 66 ++++++++++++++ examples/session-web/session_location_test.go | 66 ++++++++++++++ http/carrier.go | 55 +++++------ http/carrier_test.go | 77 ++++++++++++---- http/credentials_location_test.go | 66 ++++++++++++++ session/cookie.go | 19 ++++ session/cookie_test.go | 91 +++++++++++++++++++ session/manager.go | 30 +++--- session/testing_helpers_test.go | 27 ++++-- 17 files changed, 571 insertions(+), 70 deletions(-) create mode 100644 examples/basic-http/credentials_location_test.go create mode 100644 examples/bearer-jwt/credentials_location_test.go create mode 100644 examples/session-web/session_location_test.go create mode 100644 http/credentials_location_test.go create mode 100644 session/cookie.go create mode 100644 session/cookie_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b077d4..b424e9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -448,6 +448,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `voter.Anonymous()` vote on. - `security.RolePrefix` (`ROLE_`) and `security.ScopePrefix` (`scope:`): the authority namespaces the voter package matches roles and scopes in. +- `httpsec.Carrier.CookiesNamed` and `session.CookieReader`: the explicit + accessor the session `Manager` reads its cookie through. ### Changed @@ -645,6 +647,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree never match: none, an empty one, a role starting with `ROLE_`, a scope that is not an RFC 6749 §3.3 scope-token or starts with `scope:`. See [MIGRATION.md](MIGRATION.md). +- `httpsec.Carrier.Get` and `Values` read the request's header fields only; + they no longer fall back to a cookie or to the query string named like + the key. `session.Manager` reads its cookie through the carrier's + `CookiesNamed` accessor (`session.CookieReader`) and finds no session with + a carrier that lacks it. See [MIGRATION.md](MIGRATION.md). - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -1208,3 +1215,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - `voter.Not()` turned the refusal of an unauthenticated principal into a grant (`Not(HasRole("BANNED"))` admitted anonymous callers); it no longer does. +- `httpsec.Carrier` answered `Get`/`Values` from a cookie or from the query + string when the header was absent: Basic and Bearer credentials were + accepted from `?Authorization=…` and from a cookie named `Authorization` + (leaking through logs, `Referer` and history), and a session from + `?session=…` or from a `Session` request header — which even took + precedence over the victim's cookie — so a link or a cross-site form + logged the victim into the attacker's session. Credentials are now read + from header fields only, and the session from its cookie only. diff --git a/CLAUDE.md b/CLAUDE.md index 7bd2240f..597b03f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,7 +189,10 @@ Carrier ──> Extractor ──> Authentication ──> Manager/Authenticator ``` - **`Carrier`** — abstracts a transport message (HTTP request, gRPC metadata) - with `http.Header`-like Get/Set/Add/Values. Adapters wrap it. + with `http.Header`-like Get/Set/Add/Values. Adapters wrap it. Get/Values + read header fields only — never the query string or cookies; the session + module reads its cookie through `httpsec.Carrier.CookiesNamed` + (`session.CookieReader`). - **`Extractor`** — pulls raw credentials from a `Carrier` into an *unauthenticated* `Authentication`. Returns `(nil, nil)` when its scheme is absent (engine tries the next); `(nil, err)` when present-but-malformed. diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 8397aa52..c92a6a7b 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -351,6 +351,10 @@ PKCE. Not supported, and refused where a provider could select them: cookie, there is no server-side session store. This covers the common case without server state, but a session cannot be revoked server-side before its cookie expires. A server-side store (Redis/SQL) is not shipped. +- The session cookie is read through `httpsec.Carrier` (its + `CookiesNamed` accessor). The `grpcsec` and `connectrpcsec` carriers expose + no cookies: a cookie session does not authenticate gRPC or ConnectRPC + calls (Connect-Web clients included). ## Logging diff --git a/MIGRATION.md b/MIGRATION.md index 51acee11..371c053c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1382,3 +1382,33 @@ To migrate: 4. Pass the bare name to `HasRole`/`security.Role` (`"ADMIN"`, not `"ROLE_ADMIN"`), and valid RFC 6749 §3.3 scope-tokens to `HasScope`/`security.Scope`. + +### `http`, `session`: credentials from header fields only, the session from its cookie only + +`httpsec.Carrier.Get` and `Values` fell back to a cookie, then to the query +string, named like the key. The Basic and Bearer extractors read +`Get("Authorization")`, so credentials were accepted from `?Authorization=…` +and from a cookie named `Authorization`; and `session.Manager` read +`Get(cookieName)`, so a session was accepted from `?session=…` and from a +`Session` request header, which even took precedence over the real cookie. +Credentials in URLs leak through access logs, `Referer` and history (RFC +6750 §5.3), and a link or a cross-site form carrying an attacker's session +value logged the victim into the attacker's account. + +| Request | Before | After | +| --- | --- | --- | +| `Authorization` header (Basic, Bearer) | authenticated | unchanged | +| `?Authorization=Basic …` / `?Authorization=Bearer …` | authenticated | no credential (401 without anonymous fallback) | +| Cookie `Authorization="Bearer …"` | authenticated | no credential | +| Session cookie | session | unchanged | +| `?session=` or `Session: ` header, no cookie | session | no session | +| Session cookie and a `Session` header | the header's session | the cookie's session | +| `session.Manager` used with a carrier without `CookiesNamed` | the carrier's `Get(cookieName)` | no session (`ErrNoSession`) | + +To migrate: + +1. Send credentials in the `Authorization` header and the session in its + cookie; nothing else is read. +2. A custom `security.Carrier` used with `session.Manager` must implement + `session.CookieReader` (`CookiesNamed(name string) []*http.Cookie`, as + `httpsec.Carrier` does); its `Get`/`Values` must read header fields only. diff --git a/carrier.go b/carrier.go index fce27ef3..46abf11c 100644 --- a/carrier.go +++ b/carrier.go @@ -16,13 +16,18 @@ package security // Implementations MUST be safe for concurrent reads but MAY require external // synchronization for writes — adapters are expected to wrap a request scope, // which is serial by construction. +// +// Get and Values read the transport's header fields (HTTP headers, gRPC +// metadata) and nothing else: an implementation MUST NOT fall back to the +// URL query string or to cookies, so that a credential is accepted only from +// the field its scheme defines and never leaks through URLs (RFC 6750 §5.3). type Carrier interface { - // Get returns the first value associated with the given key, or the - // empty string if absent. Keys are case-insensitive in the HTTP sense. + // Get returns the first value of the header field key, or the empty + // string if absent. Keys are case-insensitive in the HTTP sense. Get(key string) string - // Values returns all values associated with the given key, or a nil - // slice if absent. The caller MUST NOT mutate the returned slice. + // Values returns all values of the header field key, or a nil slice if + // absent. The caller MUST NOT mutate the returned slice. Values(key string) []string // Set replaces all values associated with the given key. diff --git a/docs/architecture.md b/docs/architecture.md index 2772002e..83544fbe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,9 +118,11 @@ Carrier ──▶ Extractor ──▶ Authentication (pending) ``` - **`Carrier`** abstracts a transport message — read credentials, write - challenges. `httpsec.Carrier` wraps `*http.Request`/`http.ResponseWriter`; - `grpcsec.Carrier` wraps `metadata.MD`; `connectrpcsec.Carrier` wraps - `http.Header`. + challenges. Reads cover the header fields only, never the URL or cookies. + `httpsec.Carrier` wraps `*http.Request`/`http.ResponseWriter` and adds the + explicit `CookiesNamed` accessor the session module reads its cookie + through; `grpcsec.Carrier` wraps `metadata.MD`; `connectrpcsec.Carrier` + wraps `http.Header`. - **`Extractor`** pulls raw, unauthenticated credentials from a `Carrier`. Returns `(nil, nil)` when its scheme is absent. - **`Authenticator`** validates a pending `Authentication` and returns an diff --git a/docs/security-considerations.md b/docs/security-considerations.md index 29d02f6c..be932115 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -869,6 +869,10 @@ session store to compromise or scale. - **Cookie attributes** — defaults are conservative: `Secure=true`, `HttpOnly=true`, `SameSite=Lax`. Disable `Secure` only for local plain-HTTP development. +- **Cookie only** — the `Manager` reads the session from the request's + `Cookie` header field, through `httpsec.Carrier.CookiesNamed`, never from + the URL nor from a header named like the cookie: a link carrying a session + value cannot log a browser into another account. - **Session fixation** — `Manager.Rotate` mints a fresh session ID; call it immediately after a privilege change (login). The ID never appears raw in a span — only a SHA-256 fingerprint. @@ -1091,6 +1095,15 @@ refused: when tenants supply URLs ("test this connection"), log the error and show them a generic message, or the error text becomes an oracle on the internal network. +## Credentials in header fields only + +`httpsec.Carrier.Get`/`Values` (and the gRPC and ConnectRPC carriers) read +header fields only: the Basic and Bearer extractors accept credentials from +the `Authorization` field alone, never from the URL query string (RFC 6750 +§5.3: tokens in URLs leak through logs, `Referer` and browser history) nor +from a cookie named like the field (an ambient credential a cross-site +request would carry). + ## Authorization Authorization follows Spring Security's voter model, and fails closed: diff --git a/examples/basic-http/credentials_location_test.go b/examples/basic-http/credentials_location_test.go new file mode 100644 index 00000000..2b8926a5 --- /dev/null +++ b/examples/basic-http/credentials_location_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-02 (probe TestZZAuditBasicCredentialsFromQueryAndCookie): Basic +// credentials were accepted from ?Authorization=… and from a cookie. +func TestBasicHTTPReadsCredentialsFromTheAuthorizationHeaderOnly(t *testing.T) { + t.Parallel() + + handler, err := newServer() + require.NoError(t, err) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + creds := "Basic " + base64.StdEncoding.EncodeToString([]byte("alice:alice-secret")) + + inQuery, err := http.NewRequest(http.MethodGet, srv.URL+"/?Authorization="+url.QueryEscape(creds), nil) + require.NoError(t, err) + + inCookie, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) + require.NoError(t, err) + inCookie.Header.Set("Cookie", `Authorization="`+creds+`"`) + + inHeader, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) + require.NoError(t, err) + inHeader.Header.Set("Authorization", creds) + + for name, c := range map[string]struct { + req *http.Request + want int + }{ + "query string": {inQuery, http.StatusUnauthorized}, + "cookie": {inCookie, http.StatusUnauthorized}, + "header (ctrl)": {inHeader, http.StatusOK}, + } { + r, err := srv.Client().Do(c.req) + require.NoError(t, err, name) + _ = r.Body.Close() + + assert.Equal(t, c.want, r.StatusCode, name) + } +} diff --git a/examples/bearer-jwt/credentials_location_test.go b/examples/bearer-jwt/credentials_location_test.go new file mode 100644 index 00000000..a1686c53 --- /dev/null +++ b/examples/bearer-jwt/credentials_location_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-02 (probe TestZZAuditBearerTokenFromQueryAndCookie): a bearer +// token was accepted from ?Authorization=… and from a cookie, contrary to +// RFC 6750 §5.3 and to the bearer package documentation. +func TestBearerJWTReadsTheTokenFromTheAuthorizationHeaderOnly(t *testing.T) { + t.Parallel() + + handler, err := newServer() + require.NoError(t, err) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + resp, err := srv.Client().Post(srv.URL+"/token", "", nil) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + var minted struct { + AccessToken string `json:"access_token"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&minted)) + + bearer := "Bearer " + minted.AccessToken + + inQuery, err := http.NewRequest(http.MethodGet, srv.URL+"/?Authorization="+url.QueryEscape(bearer), nil) + require.NoError(t, err) + + inCookie, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) + require.NoError(t, err) + inCookie.Header.Set("Cookie", `Authorization="`+bearer+`"`) + + inHeader, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) + require.NoError(t, err) + inHeader.Header.Set("Authorization", bearer) + + for name, c := range map[string]struct { + req *http.Request + want int + }{ + "query string": {inQuery, http.StatusUnauthorized}, + "cookie": {inCookie, http.StatusUnauthorized}, + "header (ctrl)": {inHeader, http.StatusOK}, + } { + r, err := srv.Client().Do(c.req) + require.NoError(t, err, name) + _ = r.Body.Close() + + assert.Equal(t, c.want, r.StatusCode, name) + } +} diff --git a/examples/session-web/session_location_test.go b/examples/session-web/session_location_test.go new file mode 100644 index 00000000..7407f20a --- /dev/null +++ b/examples/session-web/session_location_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-02 (probe examples/session-web): a session was accepted from the +// query string — a link logged a cookie-less browser into the attacker's +// session — and from a header named like the cookie. +func TestSessionWebReadsTheSessionFromItsCookieOnly(t *testing.T) { + t.Parallel() + + handler, err := newServer() + require.NoError(t, err) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + client := srv.Client() + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + + resp, err := client.PostForm(srv.URL+"/login", url.Values{"username": {"alice"}, "password": {"alice-secret"}}) + require.NoError(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusSeeOther, resp.StatusCode) + + cookies := resp.Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + + inQuery, err := http.NewRequest(http.MethodGet, srv.URL+"/?"+cookie.Name+"="+url.QueryEscape(cookie.Value), nil) + require.NoError(t, err) + + inHeader, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) + require.NoError(t, err) + inHeader.Header.Set(cookie.Name, cookie.Value) + + inCookie, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) + require.NoError(t, err) + inCookie.AddCookie(cookie) + + for name, c := range map[string]struct { + req *http.Request + want int + }{ + "query string": {inQuery, http.StatusSeeOther}, + "header": {inHeader, http.StatusSeeOther}, + "cookie (ctrl)": {inCookie, http.StatusOK}, + } { + r, err := client.Do(c.req) + require.NoError(t, err, name) + _ = r.Body.Close() + + assert.Equal(t, c.want, r.StatusCode, name) + } +} diff --git a/http/carrier.go b/http/carrier.go index ef2aa2ba..e8bbb015 100644 --- a/http/carrier.go +++ b/http/carrier.go @@ -11,9 +11,16 @@ import ( ) // Carrier adapts an *http.Request / http.ResponseWriter pair to -// [security.Carrier]. Reads consult headers first, then cookies, then query -// parameters, so header-borne credentials take precedence over URL-borne ones -// (an important defense against credentials leaking through access logs). +// [security.Carrier]. +// +// Get and Values read the request's header fields only — never the URL query +// string, never a cookie named like the key. A credential is therefore +// accepted only from the field its scheme defines (Authorization, RFC 6750 +// §2.1, RFC 7617): credentials in URLs leak through access logs, Referer and +// history (RFC 6750 §5.3), a link carrying a session would log the victim +// into the attacker's account, and a cookie named like a header would +// become an ambient credential. CookiesNamed reads the request's cookies +// explicitly; the session module reads its cookie through it. // // Writes go to the response writer's header — useful for issuing // WWW-Authenticate challenges or refreshing a session cookie. @@ -25,7 +32,7 @@ type Carrier struct { } // NewCarrier wraps the request/response pair. Either argument MAY be nil: -// - a nil request makes every Get/Values/Cookie return "" / nil; +// - a nil request makes every Get/Values/CookiesNamed return "" / nil; // - a nil response writer makes every Set/Add a no-op (useful in tests). func NewCarrier(rw http.ResponseWriter, req *http.Request) *Carrier { return &Carrier{req: req, rw: rw} @@ -41,44 +48,38 @@ func (c *Carrier) WithContext(req *http.Request) *Carrier { return &Carrier{req: req, rw: c.rw} } -// Get implements [security.Carrier]. Lookup order: header > cookie > query. +// Get implements [security.Carrier]. It returns the first value of the +// request header field key, or "" — it never reads the query string or the +// cookies. func (c *Carrier) Get(key string) string { if c.req == nil { return "" } - if v := c.req.Header.Get(key); v != "" { - return v - } - - if ck, err := c.req.Cookie(key); err == nil { - return ck.Value - } - - return c.req.URL.Query().Get(key) + return c.req.Header.Get(key) } -// Values implements [security.Carrier]. Header multi-values take precedence; -// when none are present, cookies (single value) then query parameters -// (multi-value) are consulted in that order. +// Values implements [security.Carrier]. It returns every value of the +// request header field key, or nil — it never reads the query string or the +// cookies. func (c *Carrier) Values(key string) []string { if c.req == nil { return nil } - if vs := c.req.Header.Values(key); len(vs) > 0 { - return vs - } - - if ck, err := c.req.Cookie(key); err == nil { - return []string{ck.Value} - } + return c.req.Header.Values(key) +} - if vs := c.req.URL.Query()[key]; len(vs) > 0 { - return vs +// CookiesNamed returns the request cookies named name, in the order of the +// Cookie header field lines (RFC 6265 §5.4), or none. It is the explicit +// cookie accessor the session module reads its cookie through +// (session.CookieReader). +func (c *Carrier) CookiesNamed(name string) []*http.Cookie { + if c.req == nil { + return nil } - return nil + return c.req.CookiesNamed(name) } // Set implements [security.Carrier]. It writes to the ResponseWriter's diff --git a/http/carrier_test.go b/http/carrier_test.go index 373e4d8a..aad47b20 100644 --- a/http/carrier_test.go +++ b/http/carrier_test.go @@ -14,28 +14,25 @@ import ( "github.com/stretchr/testify/require" ) -func TestCarrierLookupOrderIsHeaderThenCookieThenQuery(t *testing.T) { +// Audit CORE-02 / decision 6: Get and Values fell back to a cookie and to +// the query string named like the key, so Basic and Bearer credentials were +// accepted from ?Authorization=… and from a cookie named Authorization. +func TestCarrierReadsHeaderFieldsOnly(t *testing.T) { t.Parallel() - req := httptest.NewRequest(http.MethodGet, "/?Authorization=q", nil) - req.Header.Set("Authorization", "h") - req.AddCookie(&http.Cookie{Name: "Authorization", Value: "c"}) + outsideHeaders := httptest.NewRequest(http.MethodGet, "/?Authorization=q", nil) + outsideHeaders.AddCookie(&http.Cookie{Name: "Authorization", Value: "c"}) - c := httpsec.NewCarrier(httptest.NewRecorder(), req) - assert.Equal(t, "h", c.Get("Authorization"), "header wins") - - // Drop header -> cookie wins - req.Header.Del("Authorization") - c = httpsec.NewCarrier(httptest.NewRecorder(), req) - assert.Equal(t, "c", c.Get("Authorization")) + c := httpsec.NewCarrier(httptest.NewRecorder(), outsideHeaders) + assert.Empty(t, c.Get("Authorization"), "neither the query string nor a cookie is a header field") + assert.Nil(t, c.Values("Authorization")) - // Drop cookie too -> query wins - req2 := httptest.NewRequest(http.MethodGet, "/?Authorization=q", nil) - c = httpsec.NewCarrier(httptest.NewRecorder(), req2) - assert.Equal(t, "q", c.Get("Authorization")) + outsideHeaders.Header.Set("Authorization", "h") + assert.Equal(t, "h", c.Get("Authorization")) + assert.Equal(t, []string{"h"}, c.Values("Authorization")) } -func TestCarrierValuesPrefersHeaderMultiValues(t *testing.T) { +func TestCarrierValuesReturnsEveryHeaderValue(t *testing.T) { t.Parallel() req := httptest.NewRequest(http.MethodGet, "/?X-Foo=q1&X-Foo=q2", nil) @@ -44,6 +41,39 @@ func TestCarrierValuesPrefersHeaderMultiValues(t *testing.T) { c := httpsec.NewCarrier(httptest.NewRecorder(), req) assert.Equal(t, []string{"h1", "h2"}, c.Values("X-Foo")) + assert.Nil(t, c.Values("X-Bar")) +} + +// The session module reads its cookie through this explicit accessor, never +// through Get or Values. +func TestCarrierCookiesNamedReadsTheCookieHeaderOnly(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/?session=from-query", nil) + req.Header.Set("Session", "from-header") + req.AddCookie(&http.Cookie{Name: "session", Value: "from-cookie"}) + req.AddCookie(&http.Cookie{Name: "other", Value: "x"}) + + c := httpsec.NewCarrier(httptest.NewRecorder(), req) + + cookies := c.CookiesNamed("session") + require.Len(t, cookies, 1) + assert.Equal(t, "from-cookie", cookies[0].Value) + assert.Empty(t, c.CookiesNamed("absent")) +} + +// HTTP/2 lets a client split the Cookie field into several field lines +// (RFC 9113 §8.2.3): every line is read. +func TestCarrierCookiesNamedReadsSplitCookieFields(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("Cookie", "a=1") + req.Header.Add("Cookie", "session=s; b=2") + + cookies := httpsec.NewCarrier(nil, req).CookiesNamed("session") + require.Len(t, cookies, 1) + assert.Equal(t, "s", cookies[0].Value) } func TestCarrierSetWritesToResponseHeader(t *testing.T) { @@ -65,10 +95,25 @@ func TestCarrierWithNilRequestAndWriterIsSafe(t *testing.T) { assert.Equal(t, "", c.Get("anything")) assert.Nil(t, c.Values("anything")) + assert.Empty(t, c.CookiesNamed("anything")) c.Set("X", "Y") // must not panic c.Add("X", "Y") // must not panic } +// Audit CORE-I3: Get panicked on a request without URL (fatal for a server +// without a recovery middleware). +func TestCarrierWithoutURLOrHeaderIsSafe(t *testing.T) { + t.Parallel() + + c := httpsec.NewCarrier(httptest.NewRecorder(), &http.Request{}) + + assert.NotPanics(t, func() { + assert.Empty(t, c.Get("Authorization")) + assert.Nil(t, c.Values("Authorization")) + assert.Empty(t, c.CookiesNamed("session")) + }) +} + func TestCarrierExposesUnderlyingRequest(t *testing.T) { t.Parallel() diff --git a/http/credentials_location_test.go b/http/credentials_location_test.go new file mode 100644 index 00000000..b24d5c95 --- /dev/null +++ b/http/credentials_location_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package httpsec_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/hyperscale-stack/security" + httpsec "github.com/hyperscale-stack/security/http" + "github.com/stretchr/testify/assert" +) + +// headerExtractor reads Authorization through the carrier, like the basic +// and bearer extractors do. +type headerExtractor struct{} + +func (headerExtractor) Extract(_ context.Context, c security.Carrier) (security.Authentication, error) { + if v := c.Get("Authorization"); v == "Bearer letmein" { + return newAuth("pending"), nil + } + + return nil, nil +} + +// Audit CORE-02 (probes TestZZAuditBasicCredentialsFromQueryAndCookie and +// TestZZAuditBearerTokenFromQueryAndCookie): credentials were accepted from +// the query string and from a cookie named Authorization. +func TestMiddlewareIgnoresCredentialsOutsideHeaderFields(t *testing.T) { + t.Parallel() + + engine := security.NewEngine( + security.NewManager(&scriptedAuthn{name: "x", result: newAuth("alice").verified()}), + headerExtractor{}, + ) + handler := httpsec.Middleware(engine)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + inQuery := httptest.NewRequest(http.MethodGet, "/?Authorization="+url.QueryEscape("Bearer letmein"), nil) + + inCookie := httptest.NewRequest(http.MethodGet, "/", nil) + inCookie.Header.Set("Cookie", `Authorization="Bearer letmein"`) + + inHeader := httptest.NewRequest(http.MethodGet, "/", nil) + inHeader.Header.Set("Authorization", "Bearer letmein") + + for name, c := range map[string]struct { + req *http.Request + want int + }{ + "query string": {inQuery, http.StatusUnauthorized}, + "cookie": {inCookie, http.StatusUnauthorized}, + "header (ctrl)": {inHeader, http.StatusNoContent}, + } { + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, c.req) + + assert.Equal(t, c.want, rec.Code, name) + } +} diff --git a/session/cookie.go b/session/cookie.go new file mode 100644 index 00000000..af48cb4e --- /dev/null +++ b/session/cookie.go @@ -0,0 +1,19 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session + +import "net/http" + +// CookieReader is implemented by the carriers that expose the request's +// cookies: httpsec.Carrier does, the grpcsec and connectrpcsec carriers do +// not. The [Manager] reads its session cookie through CookiesNamed only — +// never through Get or Values, which read header fields — so a session is +// taken neither from the URL nor from a header named like the cookie. With a +// carrier that does not implement it, the request carries no session. +type CookieReader interface { + // CookiesNamed returns the request cookies named name, in order + // (http.Request.CookiesNamed). + CookiesNamed(name string) []*http.Cookie +} diff --git a/session/cookie_test.go b/session/cookie_test.go new file mode 100644 index 00000000..b1fc2f0b --- /dev/null +++ b/session/cookie_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session_test + +import ( + "context" + "testing" + + "github.com/hyperscale-stack/security/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// headerOnlyCarrier is a security.Carrier without cookie access: Get and +// Values answer the same value for every field. +type headerOnlyCarrier struct{ value string } + +func (c headerOnlyCarrier) Get(string) string { return c.value } +func (c headerOnlyCarrier) Values(string) []string { return []string{c.value} } +func (headerOnlyCarrier) Set(string, string) {} +func (headerOnlyCarrier) Add(string, string) {} + +// loginValue mints a session for sub and returns its cookie value. +func loginValue(t *testing.T, mgr *session.Manager, sub string) string { + t.Helper() + + c := newCarrier() + _, err := mgr.Login(context.Background(), c, principal{sub: sub}) + require.NoError(t, err) + + value := c.replay().cookies[mgr.CookieName()] + require.NotEmpty(t, value) + + return value +} + +// Audit CORE-02 / decision 6 (probe TestZZAuditSessionFromQueryAndHeader): +// the Manager read its cookie through Carrier.Get, so a session was accepted +// from ?session=… and from a "Session" header, which even beat the victim's +// real cookie — login CSRF and session swapping. +func TestManagerReadsTheSessionFromItsCookieOnly(t *testing.T) { + t.Parallel() + + mgr := newManager(t) + attacker := loginValue(t, mgr, "attacker") + victim := loginValue(t, mgr, "victim") + + headerOnly := newCarrier() + headerOnly.headers[mgr.CookieName()] = attacker + headerOnly.headers["Session"] = attacker + + _, err := mgr.Get(context.Background(), headerOnly) + require.ErrorIs(t, err, session.ErrNoSession, "a header named like the cookie is not the cookie") + + both := newCarrier() + both.cookies[mgr.CookieName()] = victim + both.headers["Session"] = attacker + both.headers[mgr.CookieName()] = attacker + + s, err := mgr.Get(context.Background(), both) + require.NoError(t, err) + assert.Equal(t, "victim", s.GetString("sub")) +} + +func TestManagerFailsClosedWithoutCookieReader(t *testing.T) { + t.Parallel() + + mgr := newManager(t) + + _, err := mgr.Get(context.Background(), headerOnlyCarrier{value: loginValue(t, mgr, "attacker")}) + + require.ErrorIs(t, err, session.ErrNoSession) + assert.Contains(t, err.Error(), "carrier") +} + +func TestRotateAndExtractIgnoreASessionOutsideTheCookie(t *testing.T) { + t.Parallel() + + mgr := newManager(t) + carrier := newCarrier() + carrier.headers[mgr.CookieName()] = loginValue(t, mgr, "attacker") + + _, err := mgr.Rotate(context.Background(), carrier) + require.ErrorIs(t, err, session.ErrNoSession) + + auth, err := session.NewExtractor(mgr).Extract(context.Background(), carrier) + require.NoError(t, err) + assert.Nil(t, auth) +} diff --git a/session/manager.go b/session/manager.go index 2458279d..86feee2e 100644 --- a/session/manager.go +++ b/session/manager.go @@ -24,9 +24,10 @@ const tracerName = "github.com/hyperscale-stack/security/session" // session cookie (or one that fails to decode). var ErrNoSession = errors.New("session: no session on request") -// Manager owns the session lifecycle on top of a cookie. It reads and -// writes the cookie through a [security.Carrier], so it works behind the -// HTTP adapter (httpsec) without importing it. +// Manager owns the session lifecycle on top of a cookie. It writes the +// cookie through a [security.Carrier] and reads it through the carrier's +// [CookieReader] accessor, so it works behind the HTTP adapter (httpsec) +// without importing it. // // Cookie security defaults are conservative: Secure, HttpOnly, SameSite=Lax. type Manager struct { @@ -138,21 +139,28 @@ func (m *Manager) Login(ctx context.Context, c security.Carrier, principal secur return s, nil } -// Get decodes and validates the session carried by the request. It returns -// [ErrNoSession] when the cookie is absent / undecodable and a wrapped -// expiry error when the session is past its absolute or idle deadline. -// On success it refreshes LastAccessed but does NOT rewrite the cookie — -// call [Manager.Touch] when sliding expiry is desired. +// Get decodes and validates the session carried by the request's cookie, +// read through the carrier's [CookieReader] accessor — never from a header +// field or the URL. It returns [ErrNoSession] when the cookie is absent / +// undecodable or the carrier exposes no cookies, and a wrapped expiry error +// when the session is past its absolute or idle deadline. On success it +// refreshes LastAccessed but does NOT rewrite the cookie — call +// [Manager.Touch] when sliding expiry is desired. func (m *Manager) Get(ctx context.Context, c security.Carrier) (*Session, error) { _, span := otel.Tracer(tracerName).Start(ctx, "session.Manager.Get") defer span.End() - raw := c.Get(m.cookieName) - if raw == "" { + cookies, ok := c.(CookieReader) + if !ok { + return nil, fmt.Errorf("%w: the carrier exposes no cookies", ErrNoSession) + } + + found := cookies.CookiesNamed(m.cookieName) + if len(found) == 0 || found[0].Value == "" { return nil, ErrNoSession } - s, err := m.codec.Decode(raw) + s, err := m.codec.Decode(found[0].Value) if err != nil { return nil, ErrNoSession } diff --git a/session/testing_helpers_test.go b/session/testing_helpers_test.go index 21c51e2b..f232de7a 100644 --- a/session/testing_helpers_test.go +++ b/session/testing_helpers_test.go @@ -10,33 +10,44 @@ import ( ) // mapCarrier is a minimal security.Carrier for the session tests. It models -// a browser cookie jar: cookies set via Add("Set-Cookie", …) on one -// "response" are parsed and replayed as readable cookie values on the next -// "request" via replay(). +// a browser: request header fields (Get/Values) and request cookies +// (CookiesNamed, the accessor the Manager reads its cookie through) are +// separate, and cookies set via Add("Set-Cookie", …) on one "response" are +// replayed as request cookies on the next "request" via replay(). type mapCarrier struct { - // cookies are the request-side cookies the Manager reads via Get(name). + // headers are the request header fields. + headers map[string]string + // cookies are the request cookies. cookies map[string]string // setCookies are the Set-Cookie headers the Manager staged via Add. setCookies []string } func newCarrier() *mapCarrier { - return &mapCarrier{cookies: map[string]string{}} + return &mapCarrier{headers: map[string]string{}, cookies: map[string]string{}} } func (c *mapCarrier) Get(key string) string { - // The session Manager only ever reads cookies by name. - return c.cookies[key] + return c.headers[key] } func (c *mapCarrier) Values(key string) []string { - if v, ok := c.cookies[key]; ok { + if v, ok := c.headers[key]; ok { return []string{v} } return nil } +// CookiesNamed implements session.CookieReader. +func (c *mapCarrier) CookiesNamed(name string) []*http.Cookie { + if v, ok := c.cookies[name]; ok { + return []*http.Cookie{{Name: name, Value: v}} + } + + return nil +} + func (c *mapCarrier) Set(key, value string) { if key == "Set-Cookie" { c.setCookies = []string{value} From dc62df3cbcdd6cea8bd89d73929d9e9aab52a4b3 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 00:31:22 +0200 Subject: [PATCH 07/29] fix(http): RFC 6750 and RFC 9110 challenges and status codes The default HTTP error mapping answered a Basic-only server with a bare Bearer challenge, sent no realm (RFC 7617 section 2 requires one, RFC 6750 section 3 requires an auth-param after Bearer), rendered the realm with Go escapes instead of an RFC 9110 quoted-string, sent invalid_token only for expired or unknown tokens (RFC 9068 section 4: for every failed check), never sent insufficient_scope, answered 400 when a wrong password was joined with an unsupported credential, answered an anonymous caller refused by Authorize 403 with a hard-coded bare Bearer challenge, and an infrastructure failure 401. The mapper now answers from security.ErrorClass: 400 for a malformed request (invalid_request), 503 for ErrUnavailable, 401 with invalid_token for a credential verdict, 401 without error code for no credential or an unsupported one (RFC 6750 section 3.1), 403 with insufficient_scope for an access decision, and 500 for an error that wraps no sentinel. RFC 6750 codes are sent only when the request carried a bearer token. Every challenge carries a realm (api by default), a Basic one charset="UTF-8" (RFC 7617 section 2.1), and Middleware panics at construction on a realm a quoted-string cannot carry. Authorize uses the Middleware's error mapper and asks an unauthenticated principal to authenticate (RFC 9110 sections 15.5.2 and 15.5.4). A repeated Authorization field is refused (RFC 9110 section 5.3): the Basic and Bearer extractors read it through the new security.SingleValue. The middleware span records http.request.method (_OTHER for unknown methods) and http.route as the ServeMux route template only, never the raw path. A custom Engine returning nothing no longer panics the middleware. Audit: CORE-10, CORE-I5, CORE-I6, CORE-07, CORE-I3. --- CHANGELOG.md | 26 +++ CLAUDE.md | 6 +- MIGRATION.md | 42 ++++ README.md | 3 +- basic/extractor.go | 10 +- basic/singleton_test.go | 37 ++++ bearer/extractor.go | 9 +- bearer/singleton_test.go | 36 ++++ carrier.go | 23 +++ carrier_test.go | 42 ++++ docs/architecture.md | 5 +- docs/observability.md | 9 +- docs/security-considerations.md | 29 ++- examples/basic-http/singleton_test.go | 41 ++++ examples/bearer-jwt/challenge_test.go | 108 ++++++++++ extractor.go | 4 + http/authorize.go | 49 ++++- http/authorize_test.go | 9 +- http/challenge.go | 77 +++++++ http/challenge_test.go | 141 +++++++++++++ http/error_mapper.go | 176 +++++++++------- http/extra_test.go | 4 +- http/go.mod | 2 +- http/mapping_test.go | 283 ++++++++++++++++++++++++++ http/middleware.go | 98 ++++++--- http/middleware_test.go | 9 +- http/options.go | 19 +- http/otel_testing_test.go | 104 ++++++++++ http/telemetry_test.go | 89 ++++++++ 29 files changed, 1356 insertions(+), 134 deletions(-) create mode 100644 basic/singleton_test.go create mode 100644 bearer/singleton_test.go create mode 100644 carrier_test.go create mode 100644 examples/basic-http/singleton_test.go create mode 100644 examples/bearer-jwt/challenge_test.go create mode 100644 http/challenge.go create mode 100644 http/challenge_test.go create mode 100644 http/mapping_test.go create mode 100644 http/otel_testing_test.go create mode 100644 http/telemetry_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b424e9e5..4c69be0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -450,6 +450,10 @@ legacy packages (`authentication/`, `authorization/`, the in-tree authority namespaces the voter package matches roles and scopes in. - `httpsec.Carrier.CookiesNamed` and `session.CookieReader`: the explicit accessor the session `Manager` reads its cookie through. +- `security.SingleValue(carrier, key)`: the value of a singleton header + field, refusing a repeated one with `ErrInvalidRequest`; the Basic and + Bearer extractors read `Authorization` through it. `httpsec.DefaultRealm` + (`api`). ### Changed @@ -652,6 +656,22 @@ legacy packages (`authentication/`, `authorization/`, the in-tree the key. `session.Manager` reads its cookie through the carrier's `CookiesNamed` accessor (`session.CookieReader`) and finds no session with a carrier that lacks it. See [MIGRATION.md](MIGRATION.md). +- The HTTP error mapping follows RFC 6750, RFC 7617 and RFC 9110: every + challenge carries a realm (`httpsec.DefaultRealm` unless `WithRealm`), a + Basic one `charset="UTF-8"`, the realm is an RFC 9110 quoted-string + (`Middleware` and `DefaultErrorMapper` panic on a realm one cannot carry or + a scheme that is not a token); `invalid_token` answers every failed check + of a bearer token and `insufficient_scope` every 403 of a request made + with one, and no RFC 6750 code is sent for other requests; an unsupported + credential is 401 without error code (was 400), an unauthenticated + principal refused by `Authorize` 401 (was 403), a repeated `Authorization` + field 400, an `ErrUnavailable` 503, and an error wrapping no sentinel 500 + (was 401). `Authorize` answers with the challenge of the `Middleware` in + front of it instead of a hard-coded bare `Bearer`. See + [MIGRATION.md](MIGRATION.md). +- The `httpsec.Middleware` span records `http.request.method` (`_OTHER` + for a method outside RFC 9110 / RFC 5789) instead of `http.method`, and + `http.route` only as the `ServeMux` pattern's path template. - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -1223,3 +1243,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree precedence over the victim's cookie — so a link or a cross-site form logged the victim into the attacker's session. Credentials are now read from header fields only, and the session from its cookie only. +- The `httpsec.Middleware` span recorded the raw request path as + `http.route` — reset tokens and e-mail addresses in paths reached the + trace backend — and the raw method; they are now a route template (or + absent) and a known method (or `_OTHER`). +- A request with several `Authorization` fields was authenticated with the + first one; it is now refused (400), whatever the transport. diff --git a/CLAUDE.md b/CLAUDE.md index 597b03f9..f78c4260 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -229,7 +229,11 @@ Conventions baked into the core: `WithAnonymousFallback` is set. - **Errors are sentinels** (`errors.go`) implementing the unexported `SecurityError` marker. Always wrap with `fmt.Errorf("...: %w", ErrXxx)`; - callers match with `errors.Is`/`errors.As`, never string matching. + callers match with `errors.Is`/`errors.As`, never string matching. The + transports answer from `security.ErrorClass(err)` (one precedence for + joined errors): a credential verdict is 401, `ErrUnavailable` 503, an + access denial 403 (401 for an unauthenticated principal), and an error + wrapping no sentinel 500 — never a verdict on the credentials. - **Context first.** `context.Context` is the first argument of every runtime operation (`Extract`, `Authenticate`, `Hasher.Hash`/`Verify`, `TokenVerifier.Verify`). It also carries the `Authentication` under an diff --git a/MIGRATION.md b/MIGRATION.md index 371c053c..a2608e8b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1412,3 +1412,45 @@ To migrate: 2. A custom `security.Carrier` used with `session.Manager` must implement `session.CookieReader` (`CookiesNamed(name string) []*http.Cookie`, as `httpsec.Carrier` does); its `Get`/`Values` must read header fields only. + +### `http`: challenges and status codes follow RFC 6750, RFC 7617 and RFC 9110 + +The default HTTP error mapping answered a Basic-only server with a bare +`Bearer` challenge, sent no realm (RFC 7617 §2 requires one; RFC 6750 §3 +requires an auth-param after `Bearer`), rendered the realm with Go escapes, +sent `invalid_token` only for expired or unknown tokens (RFC 9068 §4: for +every failed check), never sent `insufficient_scope`, answered `400` when a +wrong password was joined with another authenticator's bail-out, `403` to an +anonymous caller refused by `Authorize` (with a hard-coded bare `Bearer`), +and `401` to an infrastructure failure. The mapping now comes from +`security.ErrorClass`: + +| Situation | Before | After | +| --- | --- | --- | +| No credentials | `401 Bearer` | `401 Bearer realm="api"` | +| No credentials, `WithChallengeScheme("Basic")` | `401 Basic` | `401 Basic realm="api", charset="UTF-8"` | +| Bad bearer token (signature, audience, malformed…) | `401 Bearer` | `401 Bearer realm="api", error="invalid_token", …` | +| Bad credentials, no bearer token sent | `401 Bearer` | `401 Bearer realm="api"` (no RFC 6750 code) | +| Unsupported credential (no authenticator supports it) | `400`, no challenge | `401` with challenge, no error code | +| Wrong password next to an authenticator bailing out | `400`, no challenge | `401` with challenge | +| Authenticated principal refused by `Authorize` | `403 Bearer` | `403 S realm=R` (`error="insufficient_scope"` for a bearer request) | +| Unauthenticated principal refused by `Authorize` | `403 Bearer` | `401` with the `Middleware`'s challenge | +| Repeated `Authorization` field | first value used | `400` (`invalid_request` for a bearer request) | +| `ErrUnavailable` (store, key set down) | `401` | `503` | +| Error wrapping no sentinel, `ErrNoExtractor` | `401` | `500` | +| Realm with a control or non-ASCII character | Go-escaped | `Middleware` / `DefaultErrorMapper` panic | + +To migrate: + +1. Set `WithChallengeScheme("Basic")` on a Basic-only middleware, and + `WithRealm` if `api` does not suit; use printable ASCII in the realm. +2. Make clients treat `401` as "send (other) credentials", including after an + `Authorize` refusal of an anonymous request, and `503` as "retry later". +3. Authenticators: wrap `security.ErrUnavailable` for a dependency that is + temporarily down, a credential sentinel for a refusal; an error wrapping + no sentinel is now an internal error (500). +4. Custom `ErrorMapper`s: classify with `security.ErrorClass` and handle the + new classes (`invalid_request`, `unavailable`, + `authentication_required`). +5. Dashboards: the `httpsec.Middleware` span records `http.request.method` + (instead of `http.method`) and `http.route` only as a route template. diff --git a/README.md b/README.md index 48905640..fd1db64a 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,8 @@ func main() { w.Write([]byte("hello " + auth.Name())) }) - http.ListenAndServe(":8080", httpsec.Middleware(engine)(mux)) + // The challenge advertises the scheme the extractors accept (RFC 7617). + http.ListenAndServe(":8080", httpsec.Middleware(engine, httpsec.WithChallengeScheme("Basic"))(mux)) } ``` diff --git a/basic/extractor.go b/basic/extractor.go index 5d3ebc86..07fac854 100644 --- a/basic/extractor.go +++ b/basic/extractor.go @@ -34,9 +34,15 @@ func NewExtractor() Extractor { return Extractor{} } // Extract implements [security.Extractor]. Returns (nil, nil) when no Basic // credentials are present (next extractor gets a chance); a non-nil error -// for credentials that are present but malformed. +// for credentials that are present but malformed, and one wrapping +// [security.ErrInvalidRequest] when the Authorization field is repeated +// (RFC 9110 §5.3). func (Extractor) Extract(_ context.Context, c security.Carrier) (security.Authentication, error) { - header := c.Get("Authorization") + header, err := security.SingleValue(c, "Authorization") + if err != nil { + return nil, fmt.Errorf("basic: %w", err) + } + if header == "" { return nil, nil } diff --git a/basic/singleton_test.go b/basic/singleton_test.go new file mode 100644 index 00000000..473fbe44 --- /dev/null +++ b/basic/singleton_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package basic_test + +import ( + "context" + "encoding/base64" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/basic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-I5 (probe TestZZAuditMultipleAuthorizationHeaders): with several +// Authorization fields the first one silently won (RFC 9110 §5.3). +func TestExtractorRefusesARepeatedAuthorizationField(t *testing.T) { + t.Parallel() + + good := "Basic " + base64.StdEncoding.EncodeToString([]byte("alice:alice-secret")) + bad := "Basic " + base64.StdEncoding.EncodeToString([]byte("root:nope")) + + for _, values := range [][]string{{good, bad}, {bad, good}, {good, "Bearer x"}} { + c := newCarrier() + for _, v := range values { + c.Add("Authorization", v) + } + + got, err := basic.NewExtractor().Extract(context.Background(), c) + + require.ErrorIs(t, err, security.ErrInvalidRequest, "%v", values) + assert.Nil(t, got) + } +} diff --git a/bearer/extractor.go b/bearer/extractor.go index 191aa4d7..70012dd9 100644 --- a/bearer/extractor.go +++ b/bearer/extractor.go @@ -6,6 +6,7 @@ package bearer import ( "context" + "fmt" "strings" "github.com/hyperscale-stack/security" @@ -26,8 +27,14 @@ func NewExtractor() Extractor { return Extractor{} } // Bearer credentials are present (next extractor gets a chance). Returns // a non-nil Authentication carrying the raw token when the header is // well-formed; the verifier is responsible for validating the token shape. +// A repeated Authorization field is refused with an error wrapping +// [security.ErrInvalidRequest] (RFC 9110 §5.3, RFC 6750 §3.1). func (Extractor) Extract(_ context.Context, c security.Carrier) (security.Authentication, error) { - header := c.Get("Authorization") + header, err := security.SingleValue(c, "Authorization") + if err != nil { + return nil, fmt.Errorf("bearer: %w", err) + } + if header == "" { return nil, nil } diff --git a/bearer/singleton_test.go b/bearer/singleton_test.go new file mode 100644 index 00000000..e4a70614 --- /dev/null +++ b/bearer/singleton_test.go @@ -0,0 +1,36 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package bearer_test + +import ( + "context" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/bearer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-I5: with several Authorization fields the first one silently +// won (RFC 9110 §5.3: Authorization is a singleton field). +func TestExtractorRefusesARepeatedAuthorizationField(t *testing.T) { + t.Parallel() + + for _, values := range [][]string{ + {"Bearer good", "Bearer other"}, + {"Bearer good", "Basic dTpw"}, + } { + c := newCarrier() + for _, v := range values { + c.Add("Authorization", v) + } + + got, err := bearer.NewExtractor().Extract(context.Background(), c) + + require.ErrorIs(t, err, security.ErrInvalidRequest, "%v", values) + assert.Nil(t, got) + } +} diff --git a/carrier.go b/carrier.go index 46abf11c..aa0d8edc 100644 --- a/carrier.go +++ b/carrier.go @@ -4,6 +4,8 @@ package security +import "fmt" + // Carrier abstracts a transport-level message (an HTTP request, gRPC metadata, // a queue envelope) from which credentials can be read and security artifacts // (challenges, cookies, headers) can be written. @@ -36,3 +38,24 @@ type Carrier interface { // Add appends a value to the list associated with the given key. Add(key, value string) } + +// SingleValue returns the value of the singleton header field key carried by +// c — "" when the field is absent — and an error wrapping [ErrInvalidRequest] +// when the field occurs more than once. RFC 9110 §5.3 forbids a sender to +// repeat a field that is not a list, such as Authorization (§11.6.2), and +// §5.5 notes that detecting such errors improves interoperability; reading +// the first value instead would let a second credential slip past a +// component that checked the other one. Extractors read singleton credential +// fields through it. +func SingleValue(c Carrier, key string) (string, error) { + values := c.Values(key) + + switch len(values) { + case 0: + return "", nil + case 1: + return values[0], nil + default: + return "", fmt.Errorf("security: header field %q occurs %d times: %w", key, len(values), ErrInvalidRequest) + } +} diff --git a/carrier_test.go b/carrier_test.go new file mode 100644 index 00000000..c1acd920 --- /dev/null +++ b/carrier_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package security_test + +import ( + "testing" + + "github.com/hyperscale-stack/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-I5: RFC 9110 §5.3 forbids repeating a singleton field such as +// Authorization (§11.6.2); reading the first value silently ignored the +// others. +func TestSingleValue(t *testing.T) { + t.Parallel() + + c := newMapCarrier() + + v, err := security.SingleValue(c, "Authorization") + require.NoError(t, err) + assert.Empty(t, v, "absent") + + c.Add("Authorization", "") + v, err = security.SingleValue(c, "Authorization") + require.NoError(t, err) + assert.Empty(t, v, "present but empty") + + c.Set("Authorization", "Bearer one") + v, err = security.SingleValue(c, "Authorization") + require.NoError(t, err) + assert.Equal(t, "Bearer one", v) + + c.Add("Authorization", "Bearer two") + v, err = security.SingleValue(c, "Authorization") + require.ErrorIs(t, err, security.ErrInvalidRequest) + assert.Empty(t, v) + assert.Equal(t, `security: header field "Authorization" occurs 2 times: security: invalid authentication request`, err.Error()) +} diff --git a/docs/architecture.md b/docs/architecture.md index 83544fbe..4908e1a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -175,8 +175,9 @@ Adapters are deliberately thin — they translate between a transport message and a `Carrier`, then map security errors to transport responses. - **`httpsec`** — `Middleware` runs the `Engine` and enriches the request - context; `Authorize` runs an `AccessDecisionManager`. `ErrorMapper` - turns sentinels into HTTP status codes + `WWW-Authenticate`. + context; `Authorize` runs an `AccessDecisionManager` and answers with the + middleware's error mapper. `ErrorMapper` turns `security.ErrorClass` into + HTTP status codes + RFC 6750 / RFC 7617 `WWW-Authenticate` challenges. - **`grpcsec`** — `UnaryServerInterceptor` / `StreamServerInterceptor` authenticate every RPC; `UnaryAuthorize` / `StreamAuthorize` enforce an ADM. `ErrorMapper` turns sentinels into `codes.Code`. diff --git a/docs/observability.md b/docs/observability.md index 6813c505..0a266ebc 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -155,12 +155,19 @@ it yourself only behind a deliberate, low-cardinality (hashed) opt-in. | Span | When | Attributes | Error status | | ------------------- | ----------------------------- | -------------------------------------------------------------------- | ----------------------- | -| `httpsec.Middleware` | Per request through `Middleware` | `http.method` (string), `http.route` (string), `security.handled` (bool) | inherited from the core | +| `httpsec.Middleware` | Per request through `Middleware` | `http.request.method` (string), `http.route` (string, when known), `security.handled` (bool) | inherited from the core | `httpsec.Middleware` is the parent span of the core `security.Engine.*` spans for that request. `httpsec.Authorize` does **not** open its own span — it delegates to `security.AccessDecisionManager.Decide`. +Both HTTP attributes follow the OpenTelemetry semantic conventions and carry +no client-chosen value: `http.request.method` is one of the RFC 9110 / RFC +5789 methods, matched exactly, or `_OTHER` (the original method is left to +the server span of `otelhttp`); `http.route` is the path template of the +`ServeMux` pattern that routed the request (`/items/{id}`) — set only when +the middleware runs inside a route, never the raw path. + ### gRPC — `github.com/hyperscale-stack/security/grpc` | Span | When | Attributes | Error status | diff --git a/docs/security-considerations.md b/docs/security-considerations.md index be932115..c1778a3a 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1139,10 +1139,31 @@ Authorization follows Spring Security's voter model, and fails closed: ## Transport error mapping -Error mappers return terse, code-first responses. HTTP emits a status code -plus a `WWW-Authenticate` challenge; gRPC emits a `codes.Code`. Clients are -expected to branch on the code, not parse the message — the message never -leaks why authentication failed. +Error mappers return terse, code-first responses, classified once by +`security.ErrorClass` — joined errors included — so a refusal by one +authenticator is never masked by another one's lack of support, and an +outage never reads as a verdict on the credentials: + +| Class | HTTP | `WWW-Authenticate` (scheme S, realm R) | +| --- | --- | --- | +| `invalid_request` (e.g. repeated `Authorization`) | 400 | Bearer + bearer token sent: `error="invalid_request"` | +| `unavailable` (`ErrUnavailable`) | 503 | — | +| `token_expired`, `token_not_found`, `client_secret_mismatch`, `invalid_credentials` | 401 | `S realm=R`; Bearer + bearer token sent: `error="invalid_token"` (RFC 9068 §4) | +| `authentication_required`, `unsupported_credential` | 401 | `S realm=R` — no error code (RFC 6750 §3.1) | +| `insufficient_scope`, `access_denied` | 403 | `S realm=R`; Bearer + bearer token sent: `error="insufficient_scope"` | +| `no_extractor`, `_OTHER` (no sentinel: a broken contract) | 500 | — | + +Every 401 carries a challenge with a realm (`api` by default; RFC 9110 +§11.6.1, RFC 7617 §2, RFC 6750 §3), a Basic one also `charset="UTF-8"` +(RFC 7617 §2.1), and the realm is sent as an RFC 9110 quoted-string — a +realm a quoted-string cannot carry is refused at startup. An unauthenticated +principal refused by `httpsec.Authorize` is asked to authenticate (401), an +authenticated one refused (403) (RFC 9110 §15.5.2, §15.5.4); `Authorize` +answers with the challenge of the `Middleware` in front of it. The RFC 6750 +error codes are sent only when the request carried a bearer token. The +`error_description` is a fixed string per code: the response never says why +authentication failed. Clients are expected to branch on the status and the +code, not parse the message. ## Observability diff --git a/examples/basic-http/singleton_test.go b/examples/basic-http/singleton_test.go new file mode 100644 index 00000000..8d25bec9 --- /dev/null +++ b/examples/basic-http/singleton_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-I5 (probe TestZZAuditMultipleAuthorizationHeaders): with a good +// and a bad Authorization line the first one silently won (RFC 9110 §5.3). +func TestBasicHTTPRefusesARepeatedAuthorizationField(t *testing.T) { + t.Parallel() + + handler, err := newServer() + require.NoError(t, err) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + good := "Basic " + base64.StdEncoding.EncodeToString([]byte("alice:alice-secret")) + bad := "Basic " + base64.StdEncoding.EncodeToString([]byte("root:nope")) + + req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) + require.NoError(t, err) + req.Header.Add("Authorization", good) + req.Header.Add("Authorization", bad) + + resp, err := srv.Client().Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} diff --git a/examples/bearer-jwt/challenge_test.go b/examples/bearer-jwt/challenge_test.go new file mode 100644 index 00000000..1fbb4da1 --- /dev/null +++ b/examples/bearer-jwt/challenge_test.go @@ -0,0 +1,108 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + jwtsec "github.com/hyperscale-stack/security/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + bareChallenge = `Bearer realm="api"` + invalidTokenChallenge = `Bearer realm="api", error="invalid_token", error_description="The access token is invalid or has expired."` +) + +// foreignToken is a well-formed token for the example's issuer and audience, +// signed by a key the example does not trust. +func foreignToken(t *testing.T) string { + t.Helper() + + _, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + now := time.Now() + + token, err := jwtsec.NewSigner(jwtsec.PrivateKey{KeyID: keyID, Algorithm: jwtsec.EdDSA, Key: priv}). + Sign(context.Background(), &jwtsec.StandardClaims{ + Issuer: issuer, + Subject: "mallory", + Audience: jwtsec.Audience{audience}, + IssuedAt: jwtsec.NewNumericDate(now), + ExpiresAt: jwtsec.NewNumericDate(now.Add(time.Hour)), + Scope: "resource:read", + }) + require.NoError(t, err) + + return token +} + +// Audit CORE-10 (probe TestZZAuditBearerChallengeErrorCodes): a bad signature +// or a malformed token got a bare "Bearer" challenge. RFC 6750 §3: the +// challenge carries a realm; RFC 9068 §4: every failed check of a JWT +// access token carries error="invalid_token". (The expired-token and +// wrong-scope rows need the example's signing key: they come with the +// token-minting hook of a later change.) +func TestBearerJWTChallenges(t *testing.T) { + t.Parallel() + + handler, err := newServer() + require.NoError(t, err) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + resp, err := srv.Client().Post(srv.URL+"/token", "", nil) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + var minted struct { + AccessToken string `json:"access_token"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&minted)) + + cases := []struct { + name string + authorization []string + status int + challenge []string + }{ + {"no token", nil, http.StatusUnauthorized, []string{bareChallenge}}, + {"malformed token", []string{"Bearer not-a-jwt"}, http.StatusUnauthorized, []string{invalidTokenChallenge}}, + {"untrusted signature", []string{"Bearer " + foreignToken(t)}, http.StatusUnauthorized, []string{invalidTokenChallenge}}, + {"repeated Authorization", []string{"Bearer " + minted.AccessToken, "Bearer not-a-jwt"}, http.StatusBadRequest, + []string{`Bearer realm="api", error="invalid_request", error_description="The request is malformed."`}}, + {"valid token", []string{"Bearer " + minted.AccessToken}, http.StatusOK, nil}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodGet, srv.URL+"/reports", nil) + require.NoError(t, err) + + for _, v := range c.authorization { + req.Header.Add("Authorization", v) + } + + r, err := srv.Client().Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Body.Close() }) + + assert.Equal(t, c.status, r.StatusCode) + assert.Equal(t, c.challenge, r.Header.Values("WWW-Authenticate")) + }) + } +} diff --git a/extractor.go b/extractor.go index 03bceaa6..d44632d2 100644 --- a/extractor.go +++ b/extractor.go @@ -20,6 +20,10 @@ import "context" // surfaces err to the caller and stops; downstream authenticators are // not invoked. // +// Read credentials from header fields only, and a singleton field such as +// Authorization through [SingleValue]: a repeated field is a malformed +// request ([ErrInvalidRequest]), not an invitation to pick one value. +// // Implementations MUST be safe for concurrent use. type Extractor interface { Extract(ctx context.Context, c Carrier) (Authentication, error) diff --git a/http/authorize.go b/http/authorize.go index c534bd92..52a62d64 100644 --- a/http/authorize.go +++ b/http/authorize.go @@ -5,6 +5,8 @@ package httpsec import ( + "errors" + "fmt" "net/http" "github.com/hyperscale-stack/security" @@ -14,31 +16,48 @@ import ( // to decide whether the request may proceed. It MUST be installed AFTER // [Middleware] so that the request context carries an [security.Authentication]. // -// On grant, the next handler runs. On deny, the configured [ErrorMapper] -// writes a response — typically 403 Forbidden. If the request never went -// through [Middleware] (no Authentication in context), the anonymous value -// is presented to the ADM, which generally denies. +// On grant, the next handler runs. On deny, it answers with the error mapper +// of the [Middleware] that ran before it — same challenge scheme and realm — +// or, without one, [DefaultErrorMapper]("Bearer", [DefaultRealm]): 403 for an +// authenticated principal, 401 with a challenge for an unauthenticated one +// (RFC 9110 §15.5.2, §15.5.4: it lacks credentials, it is asked for them). +// Authorize panics if adm is nil. func Authorize(adm security.AccessDecisionManager, attrs ...security.Attribute) func(http.Handler) http.Handler { - return AuthorizeWith(adm, DefaultErrorMapper("Bearer", ""), attrs...) + if adm == nil { + panic("httpsec: Authorize: nil AccessDecisionManager") + } + + return AuthorizeWith(adm, nil, attrs...) } // AuthorizeWith is the explicit-mapper variant of [Authorize] — useful for -// authoritative servers that want a structured error body. +// authoritative servers that want a structured error body. A nil mapper +// behaves like [Authorize]. func AuthorizeWith( adm security.AccessDecisionManager, mapper ErrorMapper, attrs ...security.Attribute, ) func(http.Handler) http.Handler { - if mapper == nil { - mapper = DefaultErrorMapper("Bearer", "") + if adm == nil { + panic("httpsec: Authorize: nil AccessDecisionManager") } + fallback := DefaultErrorMapper(defaultChallengeScheme, DefaultRealm) + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth, _ := security.FromContext(r.Context()) if err := adm.Decide(r.Context(), auth, attrs); err != nil { - mapper.Map(w, r, err) + m := mapper + if m == nil { + var ok bool + if m, ok = errorMapperFrom(r.Context()); !ok { + m = fallback + } + } + + m.Map(w, r, refusal(auth, err)) return } @@ -47,3 +66,15 @@ func AuthorizeWith( }) } } + +// refusal turns an access denial of an unauthenticated principal into +// "authentication required": the caller lacks credentials and is asked for +// them (401) rather than refused (403). Any other error passes through. +func refusal(auth security.Authentication, err error) error { + denied := errors.Is(err, security.ErrAccessDenied) || errors.Is(err, security.ErrInsufficientScope) + if denied && !auth.IsAuthenticated() { + return fmt.Errorf("%w: %w", security.ErrAuthenticationRequired, err) + } + + return err +} diff --git a/http/authorize_test.go b/http/authorize_test.go index 4beaeb00..ae0d6bea 100644 --- a/http/authorize_test.go +++ b/http/authorize_test.go @@ -58,7 +58,7 @@ func TestAuthorizeInsufficientScopeIncludesOAuthErrorParam(t *testing.T) { ) rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/", nil) + req := bearerRequest() req = req.WithContext(security.WithAuthentication(req.Context(), newAuth("alice").verified())) h.ServeHTTP(rec, req) @@ -72,13 +72,14 @@ func TestAuthorizeInsufficientScopeIncludesOAuthErrorParam(t *testing.T) { func TestAuthorizeUsesAnonymousWhenNoAuthInContext(t *testing.T) { t.Parallel() - // scriptedADM deny -> Authorize must surface 403 even without a prior - // Middleware step (Authentication == Anonymous). + // scriptedADM deny without a prior Middleware step (Authentication == + // Anonymous): the caller is asked to authenticate (RFC 9110 §15.5.2). h := httpsec.Authorize(scriptedADM{err: security.ErrAccessDenied}, fakeAttr("scope:read"))( http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not run") }), ) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) - assert.Equal(t, http.StatusForbidden, rec.Result().StatusCode) + assert.Equal(t, http.StatusUnauthorized, rec.Result().StatusCode) + assert.Equal(t, `Bearer realm="api"`, rec.Header().Get("WWW-Authenticate")) } diff --git a/http/challenge.go b/http/challenge.go new file mode 100644 index 00000000..df7044e0 --- /dev/null +++ b/http/challenge.go @@ -0,0 +1,77 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package httpsec + +import ( + "strings" +) + +// The twin of this file lives in connectrpc/challenge.go: the dependency +// policy keeps connectrpcsec off this module, and both are pinned by the +// same table tests. + +// isToken reports whether s is an RFC 9110 §5.6.2 token — the syntax of an +// auth-scheme. +func isToken(s string) bool { + if s == "" { + return false + } + + for i := range len(s) { + c := s[i] + + switch { + case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9': + case strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0: + default: + return false + } + } + + return true +} + +// quoteString renders s as an RFC 9110 §5.6.4 quoted-string: DQUOTE and +// backslash become a quoted-pair, HTAB, SP and visible ASCII stay as they +// are, and anything else — control characters (CR, LF, NUL included, +// RFC 9110 §5.5), DEL, non-ASCII — makes ok false. +func quoteString(s string) (string, bool) { + var b strings.Builder + + b.Grow(len(s) + 2) + b.WriteByte('"') + + for i := range len(s) { + c := s[i] + + switch { + case c == '"' || c == '\\': + b.WriteByte('\\') + b.WriteByte(c) + case c == '\t' || (c >= 0x20 && c <= 0x7e): + b.WriteByte(c) + default: + return "", false + } + } + + b.WriteByte('"') + + return b.String(), true +} + +// bearerTokenSent reports whether the request carried an Authorization +// field with the Bearer scheme (auth-scheme names are case-insensitive, +// RFC 9110 §11.1): the RFC 6750 error codes describe why a request made +// with an access token was refused, so they are not sent otherwise. +func bearerTokenSent(values []string) bool { + for _, v := range values { + if scheme, _, _ := strings.Cut(v, " "); strings.EqualFold(scheme, "Bearer") { + return true + } + } + + return false +} diff --git a/http/challenge_test.go b/http/challenge_test.go new file mode 100644 index 00000000..516e28f4 --- /dev/null +++ b/http/challenge_test.go @@ -0,0 +1,141 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package httpsec_test + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hyperscale-stack/security" + httpsec "github.com/hyperscale-stack/security/http" + "github.com/stretchr/testify/assert" +) + +const ( + bearerAPI = `Bearer realm="api"` + invalidToken = `Bearer realm="api", error="invalid_token", error_description="The access token is invalid or has expired."` + insufficient = `Bearer realm="api", error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token."` + invalidRequest = `Bearer realm="api", error="invalid_request", error_description="The request is malformed."` + basicAPI = `Basic realm="api", charset="UTF-8"` + bearerTokenSent = "Bearer some-token" +) + +// mapped runs the default mapper of scheme on err for a request that carried +// the given Authorization field ("" for none). +func mapped(scheme string, err error, authorization string) (int, []string) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if authorization != "" { + req.Header.Set("Authorization", authorization) + } + + rec := httptest.NewRecorder() + httpsec.DefaultErrorMapper(scheme, "").Map(rec, req, err) + + return rec.Code, rec.Header().Values("WWW-Authenticate") +} + +func challenge(values ...string) []string { return values } + +// Audit CORE-10 / CORE-I6: RFC 6750 §3 and §3.1, RFC 7617 §2 and §2.1, +// RFC 9068 §4, RFC 9110 §11.6.1, §15.5.2, §15.5.4, §15.6.1, §15.6.4. +func TestDefaultErrorMapperAnswers(t *testing.T) { + t.Parallel() + + refused := func(causes ...error) error { + return fmt.Errorf("%w: %w", security.ErrAuthenticatorRefused, errors.Join(causes...)) + } + + cases := []struct { + name string + scheme string + err error + authorization string + status int + challenge []string + }{ + {"invalid request, bearer", "Bearer", security.ErrInvalidRequest, bearerTokenSent, http.StatusBadRequest, challenge(invalidRequest)}, + {"invalid request, no token", "Bearer", security.ErrInvalidRequest, "", http.StatusBadRequest, nil}, + {"unavailable", "Bearer", security.ErrUnavailable, bearerTokenSent, http.StatusServiceUnavailable, nil}, + {"unavailable joined with a verdict", "Bearer", refused(security.ErrTokenNotFound, security.ErrUnavailable), bearerTokenSent, http.StatusServiceUnavailable, nil}, + {"expired token", "Bearer", security.ErrTokenExpired, bearerTokenSent, http.StatusUnauthorized, challenge(invalidToken)}, + {"token not found", "Bearer", security.ErrTokenNotFound, bearerTokenSent, http.StatusUnauthorized, challenge(invalidToken)}, + {"bad signature (invalid credentials)", "Bearer", fmt.Errorf("jwt: invalid signature: %w", security.ErrInvalidCredentials), bearerTokenSent, http.StatusUnauthorized, challenge(invalidToken)}, + {"lowercase bearer header", "Bearer", security.ErrInvalidCredentials, "bearer some-token", http.StatusUnauthorized, challenge(invalidToken)}, + {"credential failure without a bearer token", "Bearer", security.ErrInvalidCredentials, "Basic dTpw", http.StatusUnauthorized, challenge(bearerAPI)}, + {"refusal masked by a bail-out", "Bearer", refused(security.ErrUnsupportedCredential, security.ErrInvalidCredentials), bearerTokenSent, http.StatusUnauthorized, challenge(invalidToken)}, + {"authentication required", "Bearer", security.ErrAuthenticationRequired, "", http.StatusUnauthorized, challenge(bearerAPI)}, + {"unsupported credential", "Bearer", security.ErrUnsupportedCredential, "Digest x", http.StatusUnauthorized, challenge(bearerAPI)}, + {"access denied, bearer", "Bearer", security.ErrAccessDenied, bearerTokenSent, http.StatusForbidden, challenge(insufficient)}, + {"insufficient scope, bearer", "Bearer", security.ErrInsufficientScope, bearerTokenSent, http.StatusForbidden, challenge(insufficient)}, + {"access denied without a bearer token", "Bearer", security.ErrAccessDenied, "", http.StatusForbidden, challenge(bearerAPI)}, + {"anonymous denial", "Bearer", fmt.Errorf("%w: %w", security.ErrAuthenticationRequired, security.ErrAccessDenied), "", http.StatusUnauthorized, challenge(bearerAPI)}, + {"no extractor", "Bearer", security.ErrNoExtractor, "", http.StatusInternalServerError, nil}, + {"no sentinel", "Bearer", errors.New("boom"), bearerTokenSent, http.StatusInternalServerError, nil}, + {"refusal without a sentinel", "Bearer", refused(errors.New("boom")), bearerTokenSent, http.StatusInternalServerError, nil}, + {"lowercase scheme keeps the codes", "bearer", security.ErrTokenExpired, bearerTokenSent, http.StatusUnauthorized, + challenge(`bearer realm="api", error="invalid_token", error_description="The access token is invalid or has expired."`)}, + {"basic, invalid credentials", "Basic", security.ErrInvalidCredentials, "Basic dTpw", http.StatusUnauthorized, challenge(basicAPI)}, + {"basic, authentication required", "Basic", security.ErrAuthenticationRequired, "", http.StatusUnauthorized, challenge(basicAPI)}, + {"basic, access denied", "Basic", security.ErrAccessDenied, "Basic dTpw", http.StatusForbidden, challenge(basicAPI)}, + {"basic, invalid request", "Basic", security.ErrInvalidRequest, "Basic dTpw", http.StatusBadRequest, nil}, + {"basic, bearer token sent", "Basic", security.ErrInvalidCredentials, bearerTokenSent, http.StatusUnauthorized, challenge(basicAPI)}, + {"custom scheme", "Negotiate", security.ErrInvalidCredentials, bearerTokenSent, http.StatusUnauthorized, challenge(`Negotiate realm="api"`)}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + status, got := mapped(c.scheme, c.err, c.authorization) + + assert.Equal(t, c.status, status) + assert.Equal(t, c.challenge, got) + }) + } +} + +func TestDefaultErrorMapperDefaultsSchemeAndRealm(t *testing.T) { + t.Parallel() + + for _, c := range []struct { + scheme, realm, want string + }{ + {"", "", bearerAPI}, + {"Basic", "", basicAPI}, + {"Bearer", "hyperscale", `Bearer realm="hyperscale"`}, + {"Bearer", "a \"quoted\" \\ realm\twith tab", `Bearer realm="a \"quoted\" \\ realm` + "\t" + `with tab"`}, + } { + rec := httptest.NewRecorder() + httpsec.DefaultErrorMapper(c.scheme, c.realm).Map(rec, httptest.NewRequest(http.MethodGet, "/", nil), security.ErrAuthenticationRequired) + + assert.Equal(t, c.want, rec.Header().Get("WWW-Authenticate")) + } +} + +// Audit CORE-10: the realm was rendered with Go's %q (\x01, é escapes +// that RFC 9110 §5.6.4 recipients decode as "x01", "u00e9"). +func TestDefaultErrorMapperRefusesAnInvalidChallengeConfiguration(t *testing.T) { + t.Parallel() + + for _, c := range []struct { + scheme, realm, want string + }{ + {"Bea rer", "", `httpsec: challenge scheme "Bea rer" is not an RFC 9110 token`}, + {"Bearer,", "", `httpsec: challenge scheme "Bearer," is not an RFC 9110 token`}, + {"Basic", "café", `httpsec: realm "café" is not printable ASCII (RFC 9110 §5.6.4 quoted-string)`}, + {"Basic", "a\x01", `httpsec: realm "a\x01" is not printable ASCII (RFC 9110 §5.6.4 quoted-string)`}, + {"Basic", "a\r\nX-Injected: 1", `httpsec: realm "a\r\nX-Injected: 1" is not printable ASCII (RFC 9110 §5.6.4 quoted-string)`}, + {"Basic", "a\x7f", `httpsec: realm "a\x7f" is not printable ASCII (RFC 9110 §5.6.4 quoted-string)`}, + } { + assert.PanicsWithValue(t, c.want, func() { httpsec.DefaultErrorMapper(c.scheme, c.realm) }) + assert.PanicsWithValue(t, c.want, func() { + httpsec.Middleware(security.NewEngine(security.NewManager()), + httpsec.WithChallengeScheme(c.scheme), httpsec.WithRealm(c.realm)) + }, "Middleware fails at construction, not per request") + } +} diff --git a/http/error_mapper.go b/http/error_mapper.go index 7f075baa..0b4528b2 100644 --- a/http/error_mapper.go +++ b/http/error_mapper.go @@ -5,9 +5,9 @@ package httpsec import ( - "errors" "fmt" "net/http" + "strings" "github.com/hyperscale-stack/security" ) @@ -16,9 +16,16 @@ import ( // configured — RFC 6750 bearer tokens. const defaultChallengeScheme = "Bearer" +// DefaultRealm is the realm of the default challenges when none is +// configured: RFC 7617 §2 requires one in a Basic challenge, and RFC 6750 §3 +// requires an auth-param after Bearer. It is a protection-space label only; +// set your own with [WithRealm]. +const DefaultRealm = "api" + // ErrorMapper translates a security error into an HTTP response. Custom // mappers can produce structured (JSON, ProtoBuf) error bodies or emit -// transport-specific challenges. +// transport-specific challenges; they should classify the error with +// [security.ErrorClass], as the default mapper does. // // Implementations MUST be safe for concurrent use and MUST write the // response status before any body bytes. @@ -27,105 +34,134 @@ type ErrorMapper interface { } // DefaultErrorMapper returns the canonical mapper used by the [Middleware] -// when WithErrorMapper is not supplied. It produces: +// when WithErrorMapper is not supplied. It answers from +// [security.ErrorClass](err), with challenge scheme S and realm R: +// +// class status WWW-Authenticate +// invalid_request 400 Bearer + bearer token sent: error="invalid_request" +// unavailable 503 — +// token_expired, token_not_found, +// client_secret_mismatch, +// invalid_credentials 401 S realm=R; Bearer + bearer token sent: error="invalid_token" +// authentication_required, +// unsupported_credential 401 S realm=R (no error code, RFC 6750 §3.1) +// insufficient_scope, access_denied 403 S realm=R; Bearer + bearer token sent: error="insufficient_scope" +// no_extractor, _OTHER 500 — // -// - 400 Bad Request for [security.ErrUnsupportedCredential] -// - 401 Unauthorized for ErrInvalidCredentials, ErrClientSecretMismatch, -// ErrTokenExpired, ErrTokenNotFound, -// ErrAuthenticatorRefused, and any other -// non-classified error -// - 403 Forbidden for ErrAccessDenied -// - 403 Forbidden with `error="insufficient_scope"` for ErrInsufficientScope +// A 401 always carries a challenge (RFC 9110 §11.6.1); a Basic challenge +// also carries charset="UTF-8" (RFC 7617 §2.1). The RFC 6750 error codes are +// sent only when the request carried a bearer token in its Authorization +// field: invalid_token for every failed check of the token (RFC 9068 §4), +// insufficient_scope for a valid token refused by the access decision. +// An unavailable dependency is not a failed check: 503, no error code. The +// error_description is a fixed, generic string per code — the error chain +// is never reflected into the response. // -// 401 and 403 responses carry a WWW-Authenticate header following RFC 7235 -// (challenge scheme + realm) and RFC 6750 §3 (error / error_description for -// OAuth2 bearer flows). The error_description is a fixed, generic string per -// RFC 6750 error code — the underlying error chain is never reflected into -// the header, so internal wrapping context cannot leak to clients. +// An empty scheme means "Bearer" and an empty realm [DefaultRealm]. +// DefaultErrorMapper panics when scheme is not an RFC 9110 token or realm +// holds a character a quoted-string cannot carry (control characters, DEL, +// non-ASCII): a challenge configuration is static, so it fails at startup. func DefaultErrorMapper(scheme, realm string) ErrorMapper { if scheme == "" { scheme = defaultChallengeScheme } - return &defaultErrorMapper{scheme: scheme, realm: realm} + if realm == "" { + realm = DefaultRealm + } + + if !isToken(scheme) { + panic(fmt.Sprintf("httpsec: challenge scheme %q is not an RFC 9110 token", scheme)) + } + + quoted, ok := quoteString(realm) + if !ok { + panic(fmt.Sprintf("httpsec: realm %q is not printable ASCII (RFC 9110 §5.6.4 quoted-string)", realm)) + } + + params := "realm=" + quoted + if strings.EqualFold(scheme, "Basic") { + params += `, charset="UTF-8"` + } + + return &defaultErrorMapper{ + challenge: scheme + " " + params, + bearer: strings.EqualFold(scheme, defaultChallengeScheme), + } } type defaultErrorMapper struct { - scheme string - realm string + // challenge is the scheme followed by its realm (and charset) auth-params. + challenge string + // bearer reports a Bearer challenge, which takes the RFC 6750 codes. + bearer bool } // Map implements [ErrorMapper]. -func (m *defaultErrorMapper) Map(w http.ResponseWriter, _ *http.Request, err error) { - status, oauthErr, desc := classify(err) +func (m *defaultErrorMapper) Map(w http.ResponseWriter, r *http.Request, err error) { + o := outcomeOf(security.ErrorClass(err)) - if status == http.StatusUnauthorized || status == http.StatusForbidden { - w.Header().Set("WWW-Authenticate", m.challenge(oauthErr, desc)) + if challenge := m.challengeFor(o, r); challenge != "" { + w.Header().Set("WWW-Authenticate", challenge) } - http.Error(w, http.StatusText(status), status) + http.Error(w, http.StatusText(o.status), o.status) } -// challenge formats an RFC 7235 / RFC 6750 challenge string. oauthErr, when -// non-empty, populates the `error` parameter so OAuth2 clients can react -// programmatically (typical values: "invalid_token", "insufficient_scope"). -// desc is a fixed, generic description: it is never derived from the error -// chain, so internal wrapping context cannot leak into the header. -func (m *defaultErrorMapper) challenge(oauthErr, desc string) string { - out := m.scheme +// challengeFor returns the WWW-Authenticate value of an outcome, or "". +func (m *defaultErrorMapper) challengeFor(o outcome, r *http.Request) string { + withCode := m.bearer && o.code != "" && r != nil && bearerTokenSent(r.Header.Values("Authorization")) - if m.realm != "" { - out += fmt.Sprintf(" realm=%q", m.realm) - } - - if oauthErr != "" { - sep := " " - if m.realm != "" { - sep = ", " + switch o.status { + case http.StatusUnauthorized, http.StatusForbidden: + if withCode { + return m.challenge + `, error="` + o.code + `", error_description="` + o.description + `"` } - out += sep + fmt.Sprintf("error=%q", oauthErr) - - if desc != "" { - out += fmt.Sprintf(`, error_description=%q`, desc) + return m.challenge + case http.StatusBadRequest: + if withCode { + return m.challenge + `, error="` + o.code + `", error_description="` + o.description + `"` } + + return "" + default: + return "" } +} - return out +// outcome is the HTTP answer to an error class, with the RFC 6750 §3.1 +// error code (and its fixed description) a Bearer challenge carries. +type outcome struct { + status int + code string + description string } -// RFC 6750 §3.1 error_description strings. These are intentionally fixed and -// generic: a verbatim error message would leak internal wrapping context -// (timestamps, package names, consumer-supplied store errors) to the client. +// RFC 6750 §3.1 error_description strings. Fixed and generic: a verbatim +// error message would leak internal wrapping context (timestamps, package +// names, consumer-supplied store errors) to the client. const ( + descInvalidRequest = "The request is malformed." descInvalidToken = "The access token is invalid or has expired." descInsufficientScope = "The request requires higher privileges than provided by the access token." ) -// classify maps an error to (httpStatus, oauthErrorCode, errorDescription). -// The oauthErrorCode and errorDescription are populated only for the cases -// RFC 6750 §3.1 calls out; errorDescription is always a fixed string. -func classify(err error) (int, string, string) { - switch { - case errors.Is(err, security.ErrUnsupportedCredential): - return http.StatusBadRequest, "", "" - - case errors.Is(err, security.ErrAccessDenied): - return http.StatusForbidden, "", "" - - case errors.Is(err, security.ErrInsufficientScope): - return http.StatusForbidden, "insufficient_scope", descInsufficientScope - - case errors.Is(err, security.ErrTokenExpired), - errors.Is(err, security.ErrTokenNotFound): - return http.StatusUnauthorized, "invalid_token", descInvalidToken - - case errors.Is(err, security.ErrInvalidCredentials), - errors.Is(err, security.ErrClientSecretMismatch), - errors.Is(err, security.ErrAuthenticatorRefused): - return http.StatusUnauthorized, "", "" - +// outcomeOf maps a [security.ErrorClass] to its HTTP answer. +func outcomeOf(class string) outcome { + switch class { + case security.ErrorClassInvalidRequest: + return outcome{http.StatusBadRequest, "invalid_request", descInvalidRequest} + case security.ErrorClassUnavailable: + return outcome{status: http.StatusServiceUnavailable} + case security.ErrorClassTokenExpired, security.ErrorClassTokenNotFound, + security.ErrorClassClientSecretMismatch, security.ErrorClassInvalidCredentials: + return outcome{http.StatusUnauthorized, "invalid_token", descInvalidToken} + case security.ErrorClassAuthenticationRequired, security.ErrorClassUnsupportedCredential: + return outcome{status: http.StatusUnauthorized} + case security.ErrorClassInsufficientScope, security.ErrorClassAccessDenied: + return outcome{http.StatusForbidden, "insufficient_scope", descInsufficientScope} default: - return http.StatusUnauthorized, "", "" + return outcome{status: http.StatusInternalServerError} } } diff --git a/http/extra_test.go b/http/extra_test.go index c722ddc9..8bd996bc 100644 --- a/http/extra_test.go +++ b/http/extra_test.go @@ -37,7 +37,9 @@ func TestCarrierWithContext(t *testing.T) { func TestWithChallengeScheme(t *testing.T) { t.Parallel() - engine := security.NewEngine(security.NewManager()) + // An extractor that finds nothing: the request carries no credential. + // (An engine without any extractor is a misconfiguration: 500.) + engine := security.NewEngine(security.NewManager(), scriptedExtractor{}) handler := httpsec.Middleware(engine, httpsec.WithChallengeScheme("Basic"))( http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) diff --git a/http/go.mod b/http/go.mod index 756f9f49..79397b4d 100644 --- a/http/go.mod +++ b/http/go.mod @@ -8,6 +8,7 @@ require ( github.com/hyperscale-stack/security v0.0.0-00010101000000-000000000000 github.com/stretchr/testify v1.12.1 go.opentelemetry.io/otel v1.47.0 + go.opentelemetry.io/otel/trace v1.47.0 ) require ( @@ -18,6 +19,5 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect go.opentelemetry.io/otel/metric v1.47.0 // indirect - go.opentelemetry.io/otel/trace v1.47.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect ) diff --git a/http/mapping_test.go b/http/mapping_test.go new file mode 100644 index 00000000..560bcb48 --- /dev/null +++ b/http/mapping_test.go @@ -0,0 +1,283 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package httpsec_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hyperscale-stack/security" + httpsec "github.com/hyperscale-stack/security/http" + "github.com/hyperscale-stack/security/voter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// singleValueExtractor reads Authorization the way the basic and bearer +// extractors do. +type singleValueExtractor struct{} + +func (singleValueExtractor) Extract(_ context.Context, c security.Carrier) (security.Authentication, error) { + v, err := security.SingleValue(c, "Authorization") + if err != nil || v == "" { + return nil, err + } + + return newAuth("pending"), nil +} + +// bailOut supports everything and bails out at runtime. +type bailOut struct{} + +func (bailOut) Supports(security.Authentication) bool { return true } + +func (bailOut) Authenticate(_ context.Context, _ security.Authentication) (security.Authentication, error) { + return nil, fmt.Errorf("out of scope: %w", security.ErrUnsupportedCredential) +} + +// brokenEngine breaks the Engine contract. +type brokenEngine struct{} + +func (brokenEngine) Process(context.Context, security.Carrier) (context.Context, security.Authentication, error) { + return nil, nil, nil +} + +func serve(h http.Handler, req *http.Request) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + return rec +} + +func next204() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) +} + +func bearerRequest() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer some-token") + + return req +} + +// Audit CORE-10 (probe TestZZAuditChallenges). +func TestMiddlewareChallengesCarryARealm(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(security.NewManager(), scriptedExtractor{}) + + rec := serve(httpsec.Middleware(engine)(next204()), httptest.NewRequest(http.MethodGet, "/", nil)) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, []string{bearerAPI}, rec.Header().Values("WWW-Authenticate")) + + rec = serve(httpsec.Middleware(engine, httpsec.WithChallengeScheme("Basic"))(next204()), httptest.NewRequest(http.MethodGet, "/", nil)) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, []string{basicAPI}, rec.Header().Values("WWW-Authenticate")) +} + +// The audit's surviving mutant "invalid credentials tolerated under +// anonymous fallback": the fallback admits requests without credentials, +// never requests with bad ones. +func TestMiddlewareInvalidCredentialsUnderAnonymousFallbackAre401(t *testing.T) { + t.Parallel() + + engine := security.NewEngine( + security.NewManager(&scriptedAuthn{name: "x", err: security.ErrInvalidCredentials}), + scriptedExtractor{auth: newAuth("alice")}, + ) + + rec := serve(httpsec.Middleware(engine, httpsec.WithAnonymousFallback(true))(next204()), bearerRequest()) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, []string{invalidToken}, rec.Header().Values("WWW-Authenticate")) +} + +// Audit CORE-10 (probe TestZZAuditJoinedErrorClassification): an +// authenticator bailing out at runtime turned a wrong password into a 400 +// without challenge. +func TestMiddlewareCredentialFailureWinsOverABailOut(t *testing.T) { + t.Parallel() + + engine := security.NewEngine( + security.NewManager(bailOut{}, &scriptedAuthn{name: "x", err: security.ErrInvalidCredentials}), + scriptedExtractor{auth: newAuth("alice")}, + ) + + rec := serve(httpsec.Middleware(engine, httpsec.WithChallengeScheme("Basic"))(next204()), httptest.NewRequest(http.MethodGet, "/", nil)) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, []string{basicAPI}, rec.Header().Values("WWW-Authenticate")) +} + +// RFC 6750 §3.1: a request "attempted using an unsupported authentication +// method" lacks authentication information — 401 without error code. +func TestMiddlewareUnsupportedCredentialIs401WithoutErrorCode(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(security.NewManager(), scriptedExtractor{auth: newAuth("alice")}) + + rec := serve(httpsec.Middleware(engine)(next204()), bearerRequest()) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, []string{bearerAPI}, rec.Header().Values("WWW-Authenticate")) +} + +// Audit CORE-I6 / user decision: an outage is 503, a broken contract 500 — +// never a verdict on the credentials. +func TestMiddlewareAnswersOutagesAndBrokenContracts(t *testing.T) { + t.Parallel() + + extract := scriptedExtractor{auth: newAuth("alice")} + + cases := map[string]struct { + handler http.Handler + status int + }{ + "unavailable": {httpsec.Middleware(security.NewEngine(security.NewManager( + &scriptedAuthn{name: "x", err: fmt.Errorf("jwks: %w", security.ErrUnavailable)}), extract))(next204()), http.StatusServiceUnavailable}, + "no sentinel": {httpsec.Middleware(security.NewEngine(security.NewManager( + &scriptedAuthn{name: "x", err: errors.New("boom")}), extract))(next204()), http.StatusInternalServerError}, + "broken engine": {httpsec.Middleware(brokenEngine{})(next204()), http.StatusInternalServerError}, + "broken engine with fallback": {httpsec.Middleware(brokenEngine{}, httpsec.WithAnonymousFallback(true))(next204()), + http.StatusInternalServerError}, + } + + for name, c := range cases { + var rec *httptest.ResponseRecorder + + require.NotPanics(t, func() { rec = serve(c.handler, bearerRequest()) }, name) + assert.Equal(t, c.status, rec.Code, name) + assert.Empty(t, rec.Header().Values("WWW-Authenticate"), name) + } +} + +// Audit CORE-I5 (probe TestZZAuditMultipleAuthorizationHeaders): the first of +// several Authorization fields silently won. +func TestMiddlewareRefusesARepeatedAuthorizationField(t *testing.T) { + t.Parallel() + + engine := security.NewEngine( + security.NewManager(&scriptedAuthn{name: "x", result: newAuth("alice").verified()}), + singleValueExtractor{}, + ) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("Authorization", "Bearer good") + req.Header.Add("Authorization", "Bearer other") + + rec := serve(httpsec.Middleware(engine)(next204()), req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, []string{invalidRequest}, rec.Header().Values("WWW-Authenticate")) +} + +// Audit CORE-10 (probe TestZZAuditChallenges): Authorize answered an +// anonymous caller 403 with a hard-coded "Bearer" challenge, ignoring the +// middleware's scheme and realm. +func TestAuthorizeAsksAnAnonymousCallerToAuthenticate(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(security.NewManager(), scriptedExtractor{}) + adm := security.NewAffirmativeDecisionManager(voter.Authenticated()) + + h := httpsec.Middleware(engine, httpsec.WithAnonymousFallback(true), httpsec.WithChallengeScheme("Basic"), httpsec.WithRealm("api"))( + httpsec.Authorize(adm, security.IsAuthenticated)(next204())) + + rec := serve(h, httptest.NewRequest(http.MethodGet, "/private", nil)) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, []string{basicAPI}, rec.Header().Values("WWW-Authenticate")) +} + +func TestAuthorizeWithoutMiddlewareUsesTheDefaultChallenge(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) + + rec := serve(httpsec.Authorize(adm, security.Role("ADMIN"))(next204()), httptest.NewRequest(http.MethodGet, "/", nil)) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, []string{bearerAPI}, rec.Header().Values("WWW-Authenticate")) +} + +// The audit called TestAuthorizeInsufficientScopeIncludesOAuthErrorParam +// vacuous: it scripted an error the real decision manager never returned. +func TestAuthorizeAuthenticatedDenialIs403(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasScope("reports:read")) + h := httpsec.Authorize(adm, security.Scope("reports:read"))(next204()) + + withToken := bearerRequest() + withToken = withToken.WithContext(security.WithAuthentication(withToken.Context(), newAuth("svc", "scope:other").verified())) + + rec := serve(h, withToken) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, []string{insufficient}, rec.Header().Values("WWW-Authenticate")) + + session := httptest.NewRequest(http.MethodGet, "/", nil) + session = session.WithContext(security.WithAuthentication(session.Context(), newAuth("alice", "scope:other").verified())) + + rec = serve(h, session) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, []string{bearerAPI}, rec.Header().Values("WWW-Authenticate"), "no bearer token, no RFC 6750 error code") +} + +func TestAuthorizeKeepsANonAccessErrorOfACustomManager(t *testing.T) { + t.Parallel() + + h := httpsec.Authorize(scriptedADM{err: fmt.Errorf("policy store: %w", security.ErrUnavailable)}, fakeAttr("x"))(next204()) + + rec := serve(h, httptest.NewRequest(http.MethodGet, "/", nil)) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code, "an outage stays an outage for an anonymous caller") + + broken := httpsec.Authorize(scriptedADM{err: errors.New("policy bug")}, fakeAttr("x"))(next204()) + + rec = serve(broken, httptest.NewRequest(http.MethodGet, "/", nil)) + + assert.Equal(t, http.StatusInternalServerError, rec.Code, "only an access denial asks an anonymous caller to authenticate") +} + +func TestAuthorizeWithUsesItsExplicitMapper(t *testing.T) { + t.Parallel() + + custom := &customMapper{} + h := httpsec.Middleware(security.NewEngine(security.NewManager(), scriptedExtractor{}), httpsec.WithAnonymousFallback(true))( + httpsec.AuthorizeWith(scriptedADM{err: security.ErrAccessDenied}, custom, fakeAttr("x"))(next204())) + + rec := serve(h, httptest.NewRequest(http.MethodGet, "/", nil)) + + assert.True(t, custom.invoked.Load()) + assert.Equal(t, http.StatusTeapot, rec.Code) +} + +func TestAuthorizeRefusesANilDecisionManager(t *testing.T) { + t.Parallel() + + assert.PanicsWithValue(t, "httpsec: Authorize: nil AccessDecisionManager", func() { httpsec.Authorize(nil) }) + assert.PanicsWithValue(t, "httpsec: Authorize: nil AccessDecisionManager", func() { httpsec.AuthorizeWith(nil, nil) }) +} + +// RFC 6750 §3.1: a request that "lacks any authentication information" gets +// no error code, even when its Authorization field names the Bearer scheme +// without a token. +func TestMiddlewareWithoutCredentialsSendsNoErrorCode(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(security.NewManager(), scriptedExtractor{}) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer") + + rec := serve(httpsec.Middleware(engine)(next204()), req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, []string{bearerAPI}, rec.Header().Values("WWW-Authenticate")) +} diff --git a/http/middleware.go b/http/middleware.go index 8bcc167d..5fc68623 100644 --- a/http/middleware.go +++ b/http/middleware.go @@ -8,6 +8,7 @@ import ( "context" "errors" "net/http" + "strings" "github.com/hyperscale-stack/security" "go.opentelemetry.io/otel" @@ -17,14 +18,18 @@ import ( const tracerName = "github.com/hyperscale-stack/security/http" // Attribute keys emitted by the HTTP middleware. The "security." prefix -// keeps the namespace aligned with the core; the few HTTP-specific facts -// (method, route) reuse the OpenTelemetry semantic conventions. +// keeps the namespace aligned with the core; the HTTP facts reuse the +// OpenTelemetry semantic conventions. const ( - attrHTTPMethod = attribute.Key("http.method") - attrHTTPRoute = attribute.Key("http.route") - attrSecurityHandled = attribute.Key("security.handled") + attrHTTPRequestMethod = attribute.Key("http.request.method") + attrHTTPRoute = attribute.Key("http.route") + attrSecurityHandled = attribute.Key("security.handled") ) +// errEngineContract reports a custom [security.Engine] that returned no +// context or no authentication without an error: an internal error. +var errEngineContract = errors.New("httpsec: the engine returned no context or authentication") + // Middleware wires a [security.Engine] into the net/http pipeline. // // On success the next handler runs with the request context enriched via @@ -33,7 +38,13 @@ const ( // // When no extractor finds any credential the behavior depends on // [WithAnonymousFallback]: by default the request is rejected with -// 401 Unauthorized, so applications fail closed. +// 401 Unauthorized ([security.ErrAuthenticationRequired]), so applications +// fail closed. Invalid credentials are refused whatever the fallback. +// +// The middleware makes its error mapper available to [Authorize], so the +// authorization step answers with the same challenge. Middleware panics at +// construction on an invalid challenge configuration (see +// [DefaultErrorMapper]). func Middleware(engine security.Engine, opts ...Option) func(http.Handler) http.Handler { cfg := buildConfig(opts...) @@ -42,35 +53,40 @@ func Middleware(engine security.Engine, opts ...Option) func(http.Handler) http. ctx, span := otel.Tracer(tracerName).Start(r.Context(), "httpsec.Middleware") defer span.End() - span.SetAttributes( - attrHTTPMethod.String(r.Method), - attrHTTPRoute.String(routeFromContext(ctx, r)), - ) + span.SetAttributes(attrHTTPRequestMethod.String(requestMethod(r.Method))) + + if route := httpRoute(r.Pattern); route != "" { + span.SetAttributes(attrHTTPRoute.String(route)) + } + + ctx = withErrorMapper(ctx, cfg.errorMapper) - carrier := NewCarrier(w, r.WithContext(ctx)) + newCtx, auth, err := engine.Process(ctx, NewCarrier(w, r.WithContext(ctx))) - newCtx, auth, err := engine.Process(ctx, carrier) - if err != nil && !isNoCredential(err, cfg) { + switch { + case err != nil && !isNoCredential(err, cfg): cfg.errorMapper.Map(w, r, err) return - } + case newCtx == nil || auth == nil: + cfg.errorMapper.Map(w, r, errEngineContract) - if !auth.IsAuthenticated() && !cfg.anonymousFallback { - cfg.errorMapper.Map(w, r, security.ErrInvalidCredentials) + return + case !auth.IsAuthenticated() && !cfg.anonymousFallback: + cfg.errorMapper.Map(w, r, security.ErrAuthenticationRequired) return } span.SetAttributes(attrSecurityHandled.Bool(true)) - next.ServeHTTP(w, r.WithContext(newCtx)) + next.ServeHTTP(w, r.WithContext(withErrorMapper(newCtx, cfg.errorMapper))) }) } } // buildConfig applies opts to a default config — a Bearer challenge with -// an empty realm and no anonymous fallback (deny-by-default). +// the default realm and no anonymous fallback (deny-by-default). func buildConfig(opts ...Option) *config { cfg := &config{ challengeScheme: defaultChallengeScheme, @@ -99,14 +115,44 @@ func isNoCredential(err error, cfg *config) bool { return cfg.anonymousFallback } -// routeFromContext returns the http.route attribute. The stdlib mux does not -// publish a route abstraction, so we fall back to the URL path. Adapters for -// chi / gorilla / gin can install a context value under the same private -// type to override this. -func routeFromContext(_ context.Context, r *http.Request) string { - if r == nil || r.URL == nil { - return "" +// requestMethod returns the method for the http.request.method attribute: +// one of the RFC 9110 §9 methods or PATCH (RFC 5789), matched exactly — +// methods are case-sensitive — else "_OTHER", so a client cannot write +// arbitrary, unbounded tokens into the span (OpenTelemetry semantic +// conventions). +func requestMethod(method string) string { + switch method { + case http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, http.MethodOptions, + http.MethodPatch, http.MethodPost, http.MethodPut, http.MethodTrace: + return method + default: + return "_OTHER" + } +} + +// httpRoute returns the path template of a ServeMux pattern +// ("GET example.com/items/{id}" → "/items/{id}"), or "" when the request +// was not routed by a pattern (the middleware wraps the mux): the +// http.route attribute is a low-cardinality template or nothing, never the +// raw path (OpenTelemetry semantic conventions). +func httpRoute(pattern string) string { + if i := strings.IndexByte(pattern, '/'); i >= 0 { + return pattern[i:] } - return r.URL.Path + return "" +} + +// errorMapperKey is the context key under which the middleware publishes its +// error mapper for Authorize. +type errorMapperKey struct{} + +func withErrorMapper(ctx context.Context, m ErrorMapper) context.Context { + return context.WithValue(ctx, errorMapperKey{}, m) +} + +func errorMapperFrom(ctx context.Context) (ErrorMapper, bool) { + m, ok := ctx.Value(errorMapperKey{}).(ErrorMapper) + + return m, ok && m != nil } diff --git a/http/middleware_test.go b/http/middleware_test.go index a284d74f..1ff1a974 100644 --- a/http/middleware_test.go +++ b/http/middleware_test.go @@ -86,11 +86,12 @@ func TestMiddlewareErrorMappingShortCircuits(t *testing.T) { err error want int }{ - {"unsupported", security.ErrUnsupportedCredential, http.StatusBadRequest}, + {"unsupported", security.ErrUnsupportedCredential, http.StatusUnauthorized}, {"invalid", security.ErrInvalidCredentials, http.StatusUnauthorized}, {"expired", security.ErrTokenExpired, http.StatusUnauthorized}, {"not_found", security.ErrTokenNotFound, http.StatusUnauthorized}, - {"unknown", errors.New("boom"), http.StatusUnauthorized}, + {"unavailable", security.ErrUnavailable, http.StatusServiceUnavailable}, + {"unknown", errors.New("boom"), http.StatusInternalServerError}, } for _, c := range cases { @@ -123,7 +124,7 @@ func TestMiddlewareWWWAuthenticateIncludesRealm(t *testing.T) { rec := httptest.NewRecorder() httpsec.Middleware(engine, httpsec.WithRealm("hyperscale"))( http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not run") }), - ).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + ).ServeHTTP(rec, bearerRequest()) ww := rec.Header().Get("WWW-Authenticate") assert.True(t, strings.Contains(ww, `realm="hyperscale"`), @@ -152,7 +153,7 @@ func TestMiddlewareWWWAuthenticateDoesNotLeakErrorDetail(t *testing.T) { rec := httptest.NewRecorder() httpsec.Middleware(engine, httpsec.WithRealm("hyperscale"))( http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not run") }), - ).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + ).ServeHTTP(rec, bearerRequest()) ww := rec.Header().Get("WWW-Authenticate") assert.NotContains(t, ww, secret, "wrapped error detail must not leak into the header") diff --git a/http/options.go b/http/options.go index 3fc8f960..c074aa8d 100644 --- a/http/options.go +++ b/http/options.go @@ -17,21 +17,26 @@ type config struct { type Option func(*config) // WithErrorMapper overrides the [ErrorMapper] used to translate security -// errors into HTTP responses. The default mapper produces RFC 7235-compliant -// 401/403/400 responses with a configurable challenge scheme. +// errors into HTTP responses (and by [Authorize] behind this middleware). +// The default mapper is [DefaultErrorMapper] built from +// [WithChallengeScheme] and [WithRealm]. func WithErrorMapper(m ErrorMapper) Option { return func(c *config) { c.errorMapper = m } } -// WithRealm sets the "realm" parameter of WWW-Authenticate challenges sent by -// the default [ErrorMapper]. RFC 7235 §2.2 allows realm to be any quoted -// string; consumers MUST NOT rely on its value for authorisation decisions. +// WithRealm sets the "realm" parameter of the WWW-Authenticate challenges +// sent by the default [ErrorMapper]. Default: [DefaultRealm]. The realm is a +// protection-space label (RFC 9110 §11.5) sent as a quoted-string: visible +// ASCII, spaces and tabs only — anything else makes [Middleware] panic. +// Consumers MUST NOT rely on its value for authorisation decisions. func WithRealm(realm string) Option { return func(c *config) { c.realm = realm } } -// WithChallengeScheme overrides the authentication scheme advertised by the -// default [ErrorMapper] (e.g. "Bearer", "Basic"). Default: "Bearer". +// WithChallengeScheme sets the authentication scheme advertised by the +// default [ErrorMapper] — the scheme your extractors accept: "Bearer" +// (default) or "Basic". A Basic challenge carries charset="UTF-8" +// (RFC 7617 §2.1); only a Bearer challenge carries RFC 6750 error codes. func WithChallengeScheme(scheme string) Option { return func(c *config) { c.challengeScheme = scheme } } diff --git a/http/otel_testing_test.go b/http/otel_testing_test.go new file mode 100644 index 00000000..6db04b55 --- /dev/null +++ b/http/otel_testing_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package httpsec_test + +import ( + "context" + "sync" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// recordedSpan keeps the name and attributes of a span: enough to assert the +// telemetry of the middleware without pulling the OpenTelemetry SDK into +// this module. +type recordedSpan struct { + noop.Span + + name string + mu sync.Mutex + attrs map[attribute.Key]attribute.Value +} + +func (s *recordedSpan) SetAttributes(kv ...attribute.KeyValue) { + s.mu.Lock() + defer s.mu.Unlock() + + for _, a := range kv { + s.attrs[a.Key] = a.Value + } +} + +func (s *recordedSpan) attr(key string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + v, ok := s.attrs[attribute.Key(key)] + + return v.Emit(), ok +} + +type spanSink struct { + mu sync.Mutex + spans []*recordedSpan +} + +type recordingTracer struct { + noop.Tracer + + sink *spanSink +} + +func (t recordingTracer) Start(ctx context.Context, name string, _ ...trace.SpanStartOption) (context.Context, trace.Span) { + s := &recordedSpan{name: name, attrs: map[attribute.Key]attribute.Value{}} + + t.sink.mu.Lock() + t.sink.spans = append(t.sink.spans, s) + t.sink.mu.Unlock() + + return trace.ContextWithSpan(ctx, s), s +} + +type recordingProvider struct { + noop.TracerProvider + + sink *spanSink +} + +func (p recordingProvider) Tracer(string, ...trace.TracerOption) trace.Tracer { + return recordingTracer{sink: p.sink} +} + +// otelMu serialises the tests that install a tracer provider. They MUST NOT +// call t.Parallel(): the provider is process-wide. +var otelMu sync.Mutex + +// recordSpans installs a recording tracer provider while fn runs and returns +// the spans named name. +func recordSpans(name string, fn func()) []*recordedSpan { + otelMu.Lock() + defer otelMu.Unlock() + + previous := otel.GetTracerProvider() + sink := &spanSink{} + otel.SetTracerProvider(recordingProvider{sink: sink}) + + defer otel.SetTracerProvider(previous) + + fn() + + var out []*recordedSpan + + for _, s := range sink.spans { + if s.name == name { + out = append(out, s) + } + } + + return out +} diff --git a/http/telemetry_test.go b/http/telemetry_test.go new file mode 100644 index 00000000..2a72440c --- /dev/null +++ b/http/telemetry_test.go @@ -0,0 +1,89 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package httpsec_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/hyperscale-stack/security" + httpsec "github.com/hyperscale-stack/security/http" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func anonymousMiddleware() func(http.Handler) http.Handler { + return httpsec.Middleware(security.NewEngine(security.NewManager(), scriptedExtractor{}), + httpsec.WithAnonymousFallback(true)) +} + +// Audit CORE-07 (probe TestZZAuditHTTPRouteIsRawPath): the span recorded the +// raw method — any client-chosen token — under the deprecated http.method. +func TestMiddlewareSpanNormalizesTheMethod(t *testing.T) { + handler := anonymousMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + + for method, want := range map[string]string{ + "PROPFIND-XYZ": "_OTHER", + "get": "_OTHER", + http.MethodGet: http.MethodGet, + http.MethodPatch: http.MethodPatch, + "QUERY": "_OTHER", + } { + spans := recordSpans("httpsec.Middleware", func() { + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(method, "/", nil)) + }) + + require.Len(t, spans, 1, method) + + got, ok := spans[0].attr("http.request.method") + assert.True(t, ok, method) + assert.Equal(t, want, got, method) + + _, ok = spans[0].attr("http.method") + assert.False(t, ok, "the deprecated http.method key is not emitted") + } +} + +// Audit CORE-07: http.route was the raw path — reset tokens and e-mail +// addresses in the path landed in the trace backend. It is now the mux +// pattern's path template, or absent. +func TestMiddlewareSpanRouteIsTheMuxPatternOrAbsent(t *testing.T) { + const path = "/password-reset/9f2c1e7a-RESET-TOKEN/alice@example.com" + + inner := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + + routed := http.NewServeMux() + routed.Handle("GET /password-reset/{token}/{email}", anonymousMiddleware()(inner)) + + wrapped := http.NewServeMux() + wrapped.Handle("GET /password-reset/{token}/{email}", inner) + + cases := map[string]struct { + handler http.Handler + pattern string + want string + }{ + "middleware inside the route": {routed, "", "/password-reset/{token}/{email}"}, + "middleware around the mux": {anonymousMiddleware()(wrapped), "", ""}, + "pattern without a path": {anonymousMiddleware()(inner), "x", ""}, + } + + for name, c := range cases { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Pattern = c.pattern + + spans := recordSpans("httpsec.Middleware", func() { + assert.NotPanics(t, func() { c.handler.ServeHTTP(httptest.NewRecorder(), req) }, name) + }) + + require.Len(t, spans, 1, name) + + got, ok := spans[0].attr("http.route") + assert.Equal(t, c.want != "", ok, name) + assert.Equal(t, c.want, got, name) + assert.NotContains(t, got, "RESET-TOKEN", name) + } +} From 9eae076826060b360a8819f3e66a050e65e9ed4a Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 00:37:46 +0200 Subject: [PATCH 08/29] fix(grpc): generic status messages, staged headers, redacted carrier The default gRPC error mapper told the client why authentication failed ("token expired", "token not found"), contrary to the documented policy, and answered an infrastructure failure Unauthenticated. It now answers from security.ErrorClass with one fixed message per code: InvalidArgument for a malformed request (a repeated authorization value), Unavailable for ErrUnavailable, Unauthenticated for a credential verdict, a missing or unsupported credential, PermissionDenied for an access decision, Internal for an error that wraps no sentinel. An anonymous principal refused by UnaryAuthorize or StreamAuthorize gets Unauthenticated, and the authorization interceptors answer with the authentication interceptor's mapper unless given their own. The metadata an extractor staged on grpcsec.Carrier was never sent, although the documentation said the interceptor flushed it: the interceptors now send it as the response header, refused RPCs included. Printing a Carrier printed its incoming metadata, the bearer token in "authorization" included; it now prints and logs key counts only, whatever the fmt verb. A custom Engine returning no authentication no longer panics the server. ServerOptions and AuthorizeServerOptions return the unary AND stream interceptor chains as grpc.ServerOption values: the documented wiring chained only unary interceptors, leaving streaming RPCs unauthenticated; the godoc example now uses them. Audit: CORE-I6, CORE-I4, CORE-10, CORE-15, CORE-05, CORE-I3. --- CHANGELOG.md | 20 +++ LIMITATIONS.md | 4 +- MIGRATION.md | 31 ++++ docs/architecture.md | 4 +- docs/observability.md | 4 +- docs/security-considerations.md | 27 ++- grpc/authorize.go | 43 ++++- grpc/authorize_test.go | 6 +- grpc/carrier.go | 38 ++++- grpc/doc.go | 3 + grpc/error_mapper.go | 63 ++++--- grpc/error_mapper_test.go | 42 +++-- grpc/example_test.go | 38 ++--- grpc/hardening_test.go | 284 ++++++++++++++++++++++++++++++++ grpc/interceptor.go | 69 +++++--- grpc/options.go | 43 ++++- grpc/server_options.go | 45 +++++ grpc/testing_helpers_test.go | 17 +- 18 files changed, 657 insertions(+), 124 deletions(-) create mode 100644 grpc/hardening_test.go create mode 100644 grpc/server_options.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c69be0b..777974e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -454,6 +454,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree field, refusing a repeated one with `ErrInvalidRequest`; the Basic and Bearer extractors read `Authorization` through it. `httpsec.DefaultRealm` (`api`). +- `grpcsec.ServerOptions` and `grpcsec.AuthorizeServerOptions`: the + authentication and authorization interceptors for unary AND streaming + RPCs, as `grpc.ServerOption` values. ### Changed @@ -672,6 +675,14 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - The `httpsec.Middleware` span records `http.request.method` (`_OTHER` for a method outside RFC 9110 / RFC 5789) instead of `http.method`, and `http.route` only as the `ServeMux` pattern's path template. +- The gRPC error mapping answers from `security.ErrorClass` with one + generic message per code: an unsupported credential is `Unauthenticated` + (was `InvalidArgument`), an anonymous principal refused by + `UnaryAuthorize`/`StreamAuthorize` `Unauthenticated` (was + `PermissionDenied`), a repeated `authorization` value `InvalidArgument`, + an `ErrUnavailable` `Unavailable`, and an error wrapping no sentinel + `Internal` (was `Unauthenticated`). The authorization interceptors use the + authentication interceptor's error mapper unless given their own. - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -789,6 +800,10 @@ legacy packages (`authentication/`, `authorization/`, the in-tree nil `Attribute`, a nil `Authentication` passed to `Decide`, and a voter returning an undefined `Decision` now fail closed (an error, a denial, the anonymous authentication, a deny vote). +- `grpcsec` sends the metadata staged on its `Carrier` as the response + header, refused RPCs included, as its documentation said; it was dropped. + A custom `Engine` returning no authentication no longer panics the + interceptors. - The SAML Redis store answered `ErrTransactionExists` only when a second save differed from the stored transaction: an identical one succeeded. It @@ -1249,3 +1264,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree absent) and a known method (or `_OTHER`). - A request with several `Authorization` fields was authenticated with the first one; it is now refused (400), whatever the transport. +- The gRPC error messages told the client why authentication failed + (`token expired`, `token not found`); they are now one fixed string per + code. `grpcsec.Carrier` printed its incoming metadata — the bearer token + in `authorization` — with `%v`; it now prints and logs key counts only, + whatever the verb. diff --git a/LIMITATIONS.md b/LIMITATIONS.md index c92a6a7b..b1791284 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -361,7 +361,9 @@ PKCE. Not supported, and refused where a provider could select them: - **What `fmt` cannot redact** — the types that hold secrets (`oidcclient.Config` and `*Client`, `oauth2.DefaultClient`, the `jwtsec` keys, signers and key sets, `session.Codec`…) print redacted with every - `fmt` verb and in `slog` output, but fmt prints some values without + `fmt` verb and in `slog` output — as do the `grpcsec` and + `connectrpcsec` carriers, which print metadata key counts only — but fmt + prints some values without calling any of their methods: `%p` of a value that is not a pointer, a value held in an unexported field of your own types, and — for the verbs a pointer does not take (`%s`, `%q`…) — what a nested pointer points to. diff --git a/MIGRATION.md b/MIGRATION.md index a2608e8b..8b1c47b7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1454,3 +1454,34 @@ To migrate: `authentication_required`). 5. Dashboards: the `httpsec.Middleware` span records `http.request.method` (instead of `http.method`) and `http.route` only as a route template. + +### `grpc`: status codes and messages, staged headers, server options + +The gRPC error mapping told the client why authentication failed +(`token expired`, `token not found`), answered an infrastructure failure +`Unauthenticated`, an unsupported credential `InvalidArgument` and an +anonymous caller refused by `UnaryAuthorize` `PermissionDenied`; the +metadata an extractor staged on the carrier was never sent; and the +documented wiring (`grpc.ChainUnaryInterceptor` only) left streaming RPCs +unauthenticated. + +| Situation | Before | After | +| --- | --- | --- | +| Credential failure | `Unauthenticated` + reason (`token expired`…) | `Unauthenticated`, `unauthenticated` | +| Unsupported credential | `InvalidArgument` | `Unauthenticated` | +| Anonymous caller refused by `UnaryAuthorize` / `StreamAuthorize` | `PermissionDenied` | `Unauthenticated` | +| Repeated `authorization` metadata | first value used | `InvalidArgument` | +| `ErrUnavailable` | `Unauthenticated` | `Unavailable` (retryable) | +| Error wrapping no sentinel, `ErrNoExtractor` | `Unauthenticated` | `Internal` | +| Metadata staged on the `Carrier` | dropped | sent as the response header | +| `UnaryAuthorize` without `WithErrorMapper` | `DefaultErrorMapper()` | the authentication interceptor's mapper | + +To migrate: + +1. Install the stream interceptors next to the unary ones — simplest: + `grpc.NewServer(slices.Concat(grpcsec.ServerOptions(engine), + grpcsec.AuthorizeServerOptions(adm, attrs))...)`. +2. Branch on the status code, never on the message. +3. Authenticators: wrap `security.ErrUnavailable` only for a transient + dependency failure (clients retry `Unavailable`), a credential sentinel + for a refusal. diff --git a/docs/architecture.md b/docs/architecture.md index 4908e1a6..28bb43d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -180,7 +180,9 @@ and a `Carrier`, then map security errors to transport responses. HTTP status codes + RFC 6750 / RFC 7617 `WWW-Authenticate` challenges. - **`grpcsec`** — `UnaryServerInterceptor` / `StreamServerInterceptor` authenticate every RPC; `UnaryAuthorize` / `StreamAuthorize` enforce an - ADM. `ErrorMapper` turns sentinels into `codes.Code`. + ADM; `ServerOptions` / `AuthorizeServerOptions` install the unary and the + stream chains together. `ErrorMapper` turns `security.ErrorClass` into + `codes.Code` with one generic message per code. - **`connectrpcsec`** — `NewAuthenticationInterceptor` authenticates every RPC; `NewAuthorizationInterceptor` enforces an ADM. Both return a `connect.Interceptor` covering unary and streaming. `ErrorMapper` turns diff --git a/docs/observability.md b/docs/observability.md index 0a266ebc..8fd497a1 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -176,7 +176,9 @@ the middleware runs inside a route, never the raw path. | `grpcsec.Authorize` | `UnaryAuthorize` / `StreamAuthorize` | none directly — delegates to `security.AccessDecisionManager.Decide` | inherited from the core | `grpcsec` deliberately does **not** open an `rpc` span — that belongs to -`otelgrpc`, which you compose alongside these interceptors. +`otelgrpc`, which you compose alongside these interceptors. `rpc.method` is +the full method gRPC routed the call to; behind a `grpc.UnknownServiceHandler` +it is the name the client sent. ### ConnectRPC — `github.com/hyperscale-stack/security/connectrpc` diff --git a/docs/security-considerations.md b/docs/security-considerations.md index c1778a3a..d04b74a3 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1144,14 +1144,14 @@ Error mappers return terse, code-first responses, classified once by authenticator is never masked by another one's lack of support, and an outage never reads as a verdict on the credentials: -| Class | HTTP | `WWW-Authenticate` (scheme S, realm R) | -| --- | --- | --- | -| `invalid_request` (e.g. repeated `Authorization`) | 400 | Bearer + bearer token sent: `error="invalid_request"` | -| `unavailable` (`ErrUnavailable`) | 503 | — | -| `token_expired`, `token_not_found`, `client_secret_mismatch`, `invalid_credentials` | 401 | `S realm=R`; Bearer + bearer token sent: `error="invalid_token"` (RFC 9068 §4) | -| `authentication_required`, `unsupported_credential` | 401 | `S realm=R` — no error code (RFC 6750 §3.1) | -| `insufficient_scope`, `access_denied` | 403 | `S realm=R`; Bearer + bearer token sent: `error="insufficient_scope"` | -| `no_extractor`, `_OTHER` (no sentinel: a broken contract) | 500 | — | +| Class | HTTP | `WWW-Authenticate` (scheme S, realm R) | gRPC | +| --- | --- | --- | --- | +| `invalid_request` (e.g. repeated `Authorization`) | 400 | Bearer + bearer token sent: `error="invalid_request"` | `InvalidArgument` | +| `unavailable` (`ErrUnavailable`) | 503 | — | `Unavailable` | +| `token_expired`, `token_not_found`, `client_secret_mismatch`, `invalid_credentials` | 401 | `S realm=R`; Bearer + bearer token sent: `error="invalid_token"` (RFC 9068 §4) | `Unauthenticated` | +| `authentication_required`, `unsupported_credential` | 401 | `S realm=R` — no error code (RFC 6750 §3.1) | `Unauthenticated` | +| `insufficient_scope`, `access_denied` | 403 | `S realm=R`; Bearer + bearer token sent: `error="insufficient_scope"` | `PermissionDenied` | +| `no_extractor`, `_OTHER` (no sentinel: a broken contract) | 500 | — | `Internal` | Every 401 carries a challenge with a realm (`api` by default; RFC 9110 §11.6.1, RFC 7617 §2, RFC 6750 §3), a Basic one also `charset="UTF-8"` @@ -1163,7 +1163,16 @@ answers with the challenge of the `Middleware` in front of it. The RFC 6750 error codes are sent only when the request carried a bearer token. The `error_description` is a fixed string per code: the response never says why authentication failed. Clients are expected to branch on the status and the -code, not parse the message. +code, not parse the message. The gRPC messages are one fixed string per +code (`unauthenticated`, `permission denied`, …). gRPC clients commonly +retry `Unavailable`: wrap `security.ErrUnavailable` only for a transient +dependency failure. + +Install the gRPC interceptors for both RPC kinds — `grpcsec.ServerOptions` +and `grpcsec.AuthorizeServerOptions` return the unary and stream chains +together: chaining only unary interceptors leaves every streaming RPC (the +health service's `Watch`, server reflection, your own streams) +unauthenticated. ## Observability diff --git a/grpc/authorize.go b/grpc/authorize.go index 3540e610..83c1df94 100644 --- a/grpc/authorize.go +++ b/grpc/authorize.go @@ -6,6 +6,8 @@ package grpcsec import ( "context" + "errors" + "fmt" "github.com/hyperscale-stack/security" "go.opentelemetry.io/otel" @@ -15,15 +17,21 @@ import ( // UnaryAuthorize returns a unary interceptor that enforces an // [security.AccessDecisionManager] against the request's // [security.Authentication]. Install it AFTER [UnaryServerInterceptor] in -// the interceptor chain so the context already carries an authentication. +// the interceptor chain so the context already carries an authentication, +// and install [StreamAuthorize] too — or use [AuthorizeServerOptions]. // -// On grant the handler runs; on deny the configured [ErrorMapper] -// translates the decision (typically codes.PermissionDenied). +// On grant the handler runs. On deny it answers with its [WithErrorMapper], +// else the mapper of the authentication interceptor before it, else +// [DefaultErrorMapper]: codes.PermissionDenied for an authenticated +// principal, codes.Unauthenticated for an unauthenticated one. It panics if +// adm is nil. func UnaryAuthorize( adm security.AccessDecisionManager, attrs []security.Attribute, opts ...Option, ) grpc.UnaryServerInterceptor { + mustDecisionManager(adm) + cfg := buildConfig(opts...) return func( @@ -33,19 +41,22 @@ func UnaryAuthorize( handler grpc.UnaryHandler, ) (any, error) { if err := decide(ctx, adm, attrs); err != nil { - return nil, cfg.errorMapper.Map(ctx, err) + return nil, cfg.mapper(ctx).Map(ctx, err) } return handler(ctx, req) } } -// StreamAuthorize is the streaming counterpart of [UnaryAuthorize]. +// StreamAuthorize is the streaming counterpart of [UnaryAuthorize]. It +// panics if adm is nil. func StreamAuthorize( adm security.AccessDecisionManager, attrs []security.Attribute, opts ...Option, ) grpc.StreamServerInterceptor { + mustDecisionManager(adm) + cfg := buildConfig(opts...) return func( @@ -55,7 +66,7 @@ func StreamAuthorize( handler grpc.StreamHandler, ) error { if err := decide(ss.Context(), adm, attrs); err != nil { - return cfg.errorMapper.Map(ss.Context(), err) + return cfg.mapper(ss.Context()).Map(ss.Context(), err) } return handler(srv, ss) @@ -71,8 +82,26 @@ func decide(ctx context.Context, adm security.AccessDecisionManager, attrs []sec auth, _ := security.FromContext(ctx) if err := adm.Decide(ctx, auth, attrs); err != nil { - return err //nolint:wrapcheck // security.* sentinels pass through to the ErrorMapper + return refusal(auth, err) } return nil } + +// refusal turns an access denial of an unauthenticated principal into +// "authentication required" (codes.Unauthenticated): the caller lacks +// credentials. Any other error passes through. +func refusal(auth security.Authentication, err error) error { + denied := errors.Is(err, security.ErrAccessDenied) || errors.Is(err, security.ErrInsufficientScope) + if denied && !auth.IsAuthenticated() { + return fmt.Errorf("%w: %w", security.ErrAuthenticationRequired, err) + } + + return err //nolint:wrapcheck // security.* sentinels pass through to the ErrorMapper +} + +func mustDecisionManager(adm security.AccessDecisionManager) { + if adm == nil { + panic("grpcsec: nil AccessDecisionManager") + } +} diff --git a/grpc/authorize_test.go b/grpc/authorize_test.go index b95abc1d..7069e02e 100644 --- a/grpc/authorize_test.go +++ b/grpc/authorize_test.go @@ -74,8 +74,8 @@ func TestUnaryAuthorizeDeniesAnonymous(t *testing.T) { adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) - // No authentication interceptor in front: the request is anonymous, - // the role voter denies. + // No authentication interceptor in front: the request is anonymous, the + // role voter denies, and the caller is asked to authenticate. client := dialBufconn(t, grpcsec.UnaryAuthorize(adm, []security.Attribute{security.Role("ADMIN")}), nil, @@ -83,7 +83,7 @@ func TestUnaryAuthorizeDeniesAnonymous(t *testing.T) { _, err := client.Check(context.Background(), &healthpb.HealthCheckRequest{}) require.Error(t, err) - assert.Equal(t, codes.PermissionDenied, status.Code(err)) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) } func TestStreamAuthorizeGrantsAndDenies(t *testing.T) { diff --git a/grpc/carrier.go b/grpc/carrier.go index 2ea61f69..1361e904 100644 --- a/grpc/carrier.go +++ b/grpc/carrier.go @@ -6,6 +6,9 @@ package grpcsec import ( "context" + "fmt" + "io" + "log/slog" "strings" "github.com/hyperscale-stack/security" @@ -18,9 +21,11 @@ import ( // gRPC normalises metadata keys to lower-case; the Carrier lower-cases // lookups so callers can use the conventional "Authorization" spelling. // -// Writes accumulate in a private metadata.MD that the interceptor flushes -// as a response header (grpc.SetHeader) before returning. This lets an -// ErrorMapper attach, e.g., a diagnostic header alongside a status error. +// Writes accumulate in a private metadata.MD that the interceptors send as +// the response header (grpc.SetHeader), refused RPCs included: an extractor +// can attach, e.g., a challenge alongside a status error. +// +// A Carrier prints and logs its key counts only, never the metadata values. // // Carrier is NOT safe for concurrent use; one instance per RPC. type Carrier struct { @@ -66,10 +71,31 @@ func (c *Carrier) Add(key, value string) { c.out.Append(strings.ToLower(key), value) } -// ResponseMetadata returns the staged response metadata. The interceptor -// calls it after the engine / handler run and, when non-empty, pushes it -// with grpc.SetHeader. +// ResponseMetadata returns the staged response metadata. The interceptors +// call it after the engine ran and, when non-empty, send it as the response +// header (grpc.SetHeader / ServerStream.SetHeader) — before the handler +// runs, or before the status of a refused RPC. func (c *Carrier) ResponseMetadata() metadata.MD { return c.out } +// String implements fmt.Stringer with the number of incoming and staged +// metadata keys: the values — the authorization metadata among them — are +// never printed. +func (c Carrier) String() string { + return fmt.Sprintf("grpcsec.Carrier{Incoming: %d keys, Staged: %d keys}", len(c.in), len(c.out)) +} + +// GoString implements fmt.GoStringer like String. +func (c Carrier) GoString() string { return c.String() } + +// Format implements fmt.Formatter like String, for every verb: without it, +// fmt prints the metadata maps. fmt handles %p of a non-pointer value before +// any method: no type can redact that one. +func (c Carrier) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, c.String()) } + +// LogValue implements slog.LogValuer with the key counts only. +func (c Carrier) LogValue() slog.Value { + return slog.GroupValue(slog.Int("incoming_keys", len(c.in)), slog.Int("staged_keys", len(c.out))) +} + // Compile-time check. var _ security.Carrier = (*Carrier)(nil) diff --git a/grpc/doc.go b/grpc/doc.go index a1307084..c9dadb07 100644 --- a/grpc/doc.go +++ b/grpc/doc.go @@ -7,6 +7,9 @@ // It exposes unary and stream server interceptors that hand the gRPC metadata // (the Carrier) to the core Engine and map security errors to the appropriate // gRPC status codes (codes.Unauthenticated, codes.PermissionDenied, …). +// Install both kinds: ServerOptions and AuthorizeServerOptions return the +// unary and stream chains together, so no streaming RPC is left +// unauthenticated. // // Allowed dependencies: // - github.com/hyperscale-stack/security (core) diff --git a/grpc/error_mapper.go b/grpc/error_mapper.go index 9e1f254f..7c0a0a0e 100644 --- a/grpc/error_mapper.go +++ b/grpc/error_mapper.go @@ -6,7 +6,6 @@ package grpcsec import ( "context" - "errors" "github.com/hyperscale-stack/security" "google.golang.org/grpc/codes" @@ -24,17 +23,23 @@ type ErrorMapper interface { Map(ctx context.Context, err error) error } -// DefaultErrorMapper returns the canonical mapper: +// DefaultErrorMapper returns the canonical mapper. It answers from +// [security.ErrorClass](err) with one fixed message per code — the message +// never says why authentication failed (a token expired, a user unknown): // -// - codes.InvalidArgument for [security.ErrUnsupportedCredential] -// - codes.PermissionDenied for [security.ErrAccessDenied] and -// [security.ErrInsufficientScope] -// - codes.Unauthenticated for ErrInvalidCredentials, -// ErrClientSecretMismatch, ErrTokenExpired, ErrTokenNotFound, -// ErrAuthenticatorRefused, and any other unclassified error +// class code message +// invalid_request InvalidArgument "invalid request" +// unavailable Unavailable "authentication unavailable" +// token_expired, token_not_found, +// client_secret_mismatch, invalid_credentials, +// authentication_required, +// unsupported_credential Unauthenticated "unauthenticated" +// insufficient_scope, access_denied PermissionDenied "permission denied" +// no_extractor, _OTHER Internal "internal error" // -// The message is intentionally terse — gRPC clients branch on the code, -// not the string. +// gRPC clients commonly retry Unavailable: authenticators wrap +// [security.ErrUnavailable] only for a transient dependency failure, and an +// error wrapping no security sentinel is an internal error. func DefaultErrorMapper() ErrorMapper { return defaultErrorMapper{} } type defaultErrorMapper struct{} @@ -42,34 +47,24 @@ type defaultErrorMapper struct{} // Map implements [ErrorMapper]. The returned status error is the final // wire value — not a wrapping of err — so wrapcheck is silenced here. func (defaultErrorMapper) Map(_ context.Context, err error) error { - code, msg := classify(err) + code, msg := classify(security.ErrorClass(err)) return status.Error(code, msg) //nolint:wrapcheck // status error is the terminal wire value } -func classify(err error) (codes.Code, string) { - switch { - case errors.Is(err, security.ErrUnsupportedCredential): - return codes.InvalidArgument, "unsupported credential" - - case errors.Is(err, security.ErrAccessDenied): - return codes.PermissionDenied, "access denied" - - case errors.Is(err, security.ErrInsufficientScope): - return codes.PermissionDenied, "insufficient scope" - - case errors.Is(err, security.ErrTokenExpired): - return codes.Unauthenticated, "token expired" - - case errors.Is(err, security.ErrTokenNotFound): - return codes.Unauthenticated, "token not found" - - case errors.Is(err, security.ErrInvalidCredentials), - errors.Is(err, security.ErrClientSecretMismatch), - errors.Is(err, security.ErrAuthenticatorRefused): - return codes.Unauthenticated, "invalid credentials" - - default: +func classify(class string) (codes.Code, string) { + switch class { + case security.ErrorClassInvalidRequest: + return codes.InvalidArgument, "invalid request" + case security.ErrorClassUnavailable: + return codes.Unavailable, "authentication unavailable" + case security.ErrorClassTokenExpired, security.ErrorClassTokenNotFound, + security.ErrorClassClientSecretMismatch, security.ErrorClassInvalidCredentials, + security.ErrorClassAuthenticationRequired, security.ErrorClassUnsupportedCredential: return codes.Unauthenticated, "unauthenticated" + case security.ErrorClassInsufficientScope, security.ErrorClassAccessDenied: + return codes.PermissionDenied, "permission denied" + default: + return codes.Internal, "internal error" } } diff --git a/grpc/error_mapper_test.go b/grpc/error_mapper_test.go index 3cb0cec6..d1f2c2b7 100644 --- a/grpc/error_mapper_test.go +++ b/grpc/error_mapper_test.go @@ -17,34 +17,50 @@ import ( "google.golang.org/grpc/status" ) +// Audit CORE-I6 (probe TestZZAuditErrorMessages): the messages told the +// client why authentication failed ("token expired", "token not found"), +// and an infrastructure failure was reported as Unauthenticated. func TestDefaultErrorMapperClassification(t *testing.T) { t.Parallel() mapper := grpcsec.DefaultErrorMapper() + refused := func(causes ...error) error { + return fmt.Errorf("%w: %w", security.ErrAuthenticatorRefused, errors.Join(causes...)) + } + cases := []struct { name string err error - want codes.Code + code codes.Code + msg string }{ - {"unsupported_credential", security.ErrUnsupportedCredential, codes.InvalidArgument}, - {"access_denied", security.ErrAccessDenied, codes.PermissionDenied}, - {"insufficient_scope", security.ErrInsufficientScope, codes.PermissionDenied}, - {"token_expired", security.ErrTokenExpired, codes.Unauthenticated}, - {"token_not_found", security.ErrTokenNotFound, codes.Unauthenticated}, - {"invalid_credentials", security.ErrInvalidCredentials, codes.Unauthenticated}, - {"client_secret_mismatch", security.ErrClientSecretMismatch, codes.Unauthenticated}, - {"authenticator_refused", security.ErrAuthenticatorRefused, codes.Unauthenticated}, - {"unknown_defaults_to_unauthenticated", errors.New("boom"), codes.Unauthenticated}, - {"wrapped_access_denied", fmt.Errorf("ctx: %w", security.ErrAccessDenied), codes.PermissionDenied}, + {"invalid_request", security.ErrInvalidRequest, codes.InvalidArgument, "invalid request"}, + {"unavailable", security.ErrUnavailable, codes.Unavailable, "authentication unavailable"}, + {"unavailable_joined_with_a_verdict", refused(security.ErrTokenNotFound, security.ErrUnavailable), codes.Unavailable, "authentication unavailable"}, + {"token_expired", security.ErrTokenExpired, codes.Unauthenticated, "unauthenticated"}, + {"token_not_found", security.ErrTokenNotFound, codes.Unauthenticated, "unauthenticated"}, + {"invalid_credentials", security.ErrInvalidCredentials, codes.Unauthenticated, "unauthenticated"}, + {"client_secret_mismatch", security.ErrClientSecretMismatch, codes.Unauthenticated, "unauthenticated"}, + {"authenticator_refused", refused(security.ErrInvalidCredentials), codes.Unauthenticated, "unauthenticated"}, + {"authentication_required", security.ErrAuthenticationRequired, codes.Unauthenticated, "unauthenticated"}, + {"unsupported_credential", security.ErrUnsupportedCredential, codes.Unauthenticated, "unauthenticated"}, + {"access_denied", security.ErrAccessDenied, codes.PermissionDenied, "permission denied"}, + {"insufficient_scope", security.ErrInsufficientScope, codes.PermissionDenied, "permission denied"}, + {"wrapped_access_denied", fmt.Errorf("ctx: %w", security.ErrAccessDenied), codes.PermissionDenied, "permission denied"}, + {"anonymous_denial", fmt.Errorf("%w: %w", security.ErrAuthenticationRequired, security.ErrAccessDenied), codes.Unauthenticated, "unauthenticated"}, + {"no_extractor", security.ErrNoExtractor, codes.Internal, "internal error"}, + {"no_sentinel", errors.New("boom"), codes.Internal, "internal error"}, + {"refusal_without_a_sentinel", refused(errors.New("boom")), codes.Internal, "internal error"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() - got := mapper.Map(context.Background(), c.err) - assert.Equal(t, c.want, status.Code(got)) + st := status.Convert(mapper.Map(context.Background(), c.err)) + assert.Equal(t, c.code, st.Code()) + assert.Equal(t, c.msg, st.Message(), "the message never says why") }) } } diff --git a/grpc/example_test.go b/grpc/example_test.go index 77596863..961da701 100644 --- a/grpc/example_test.go +++ b/grpc/example_test.go @@ -7,19 +7,19 @@ package grpcsec_test import ( "context" "fmt" + "slices" "github.com/hyperscale-stack/security" grpcsec "github.com/hyperscale-stack/security/grpc" "github.com/hyperscale-stack/security/voter" - healthpb "google.golang.org/grpc/health/grpc_health_v1" - "google.golang.org/grpc/codes" + "google.golang.org/grpc" "google.golang.org/grpc/status" ) // Example wires the Bearer-token engine into a gRPC server: the -// authentication interceptor validates the token, the authorization -// interceptor enforces a role, and the call only reaches the handler when -// both pass. +// authentication interceptors validate the token, the authorization +// interceptors enforce a role, and a call — unary or streaming — only +// reaches its handler when both pass. func Example() { engine := security.NewEngine( security.NewManager(tokenAuthenticator{authorities: []string{"ROLE_ADMIN"}}), @@ -27,33 +27,27 @@ func Example() { ) adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) - // In a real server: - // - // grpc.NewServer( - // grpc.ChainUnaryInterceptor( - // grpcsec.UnaryServerInterceptor(engine), - // grpcsec.UnaryAuthorize(adm, []security.Attribute{security.Role("ADMIN")}), - // ), - // ) - // - // Here we just demonstrate the error mapping the interceptors apply. - _ = engine - _ = adm + // ServerOptions and AuthorizeServerOptions chain the unary AND the + // stream interceptors: chaining only unary ones would leave streaming + // RPCs unauthenticated. + srv := grpc.NewServer(slices.Concat( + grpcsec.ServerOptions(engine), + grpcsec.AuthorizeServerOptions(adm, []security.Attribute{security.Role("ADMIN")}), + )...) + defer srv.Stop() + // The interceptors answer with the default error mapping. mapper := grpcsec.DefaultErrorMapper() for _, err := range []error{ security.ErrInvalidCredentials, security.ErrAccessDenied, - security.ErrUnsupportedCredential, + security.ErrUnavailable, } { fmt.Println(status.Code(mapper.Map(context.Background(), err))) } - _ = healthpb.HealthCheckRequest{} - _ = codes.OK - // Output: // Unauthenticated // PermissionDenied - // InvalidArgument + // Unavailable } diff --git a/grpc/hardening_test.go b/grpc/hardening_test.go new file mode 100644 index 00000000..511391d2 --- /dev/null +++ b/grpc/hardening_test.go @@ -0,0 +1,284 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package grpcsec_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "slices" + "testing" + + "github.com/hyperscale-stack/security" + grpcsec "github.com/hyperscale-stack/security/grpc" + "github.com/hyperscale-stack/security/voter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// challengeExtractor stages a response header on the carrier, like an +// extractor issuing a challenge, then delegates to tokenExtractor. +type challengeExtractor struct{} + +func (challengeExtractor) Extract(ctx context.Context, c security.Carrier) (security.Authentication, error) { + c.Set("WWW-Authenticate", `Bearer realm="api"`) + + return tokenExtractor{}.Extract(ctx, c) +} + +// brokenEngine breaks the Engine contract. +type brokenEngine struct{} + +func (brokenEngine) Process(context.Context, security.Carrier) (context.Context, security.Authentication, error) { + return nil, nil, nil +} + +// Audit CORE-I4 (probe TestZZAuditCarrierWritesAreDropped): the carrier +// staged writes the documentation said the interceptor flushed; it never +// did. +func TestUnaryInterceptorSendsStagedHeaders(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(security.NewManager(tokenAuthenticator{}), challengeExtractor{}) + client := dialBufconn(t, grpcsec.UnaryServerInterceptor(engine), nil) + + for name, ctx := range map[string]context.Context{ + "refused": context.Background(), + "accepted": bearer(context.Background(), "letmein"), + } { + var header metadata.MD + + _, _ = client.Check(ctx, &healthpb.HealthCheckRequest{}, grpc.Header(&header)) + + assert.Equal(t, []string{`Bearer realm="api"`}, header.Get("www-authenticate"), name) + } +} + +func TestStreamInterceptorSendsStagedHeaders(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(security.NewManager(tokenAuthenticator{}), challengeExtractor{}) + client := dialBufconn(t, nil, grpcsec.StreamServerInterceptor(engine)) + + for name, ctx := range map[string]context.Context{ + "refused": context.Background(), + "accepted": bearer(context.Background(), "letmein"), + } { + stream, err := client.Watch(ctx, &healthpb.HealthCheckRequest{}) + require.NoError(t, err, name) + + _, _ = stream.Recv() + + header, err := stream.Header() + if err == nil { + assert.Equal(t, []string{`Bearer realm="api"`}, header.Get("www-authenticate"), name) + } else { + assert.Equal(t, []string{`Bearer realm="api"`}, stream.Trailer().Get("www-authenticate"), name) + } + } +} + +// Audit CORE-15 (probe TestZZAuditCarrierPrintsCredentials): printing a +// Carrier printed the incoming metadata, bearer token included. +func TestCarrierRedactsItself(t *testing.T) { + t.Parallel() + + const token = "eyJhbGciOi.secret.token" + + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token)) + c := grpcsec.NewCarrier(ctx) + c.Set("x-staged", "staged-value") + + var printed []string + + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%d", "%x"} { + printed = append(printed, fmt.Sprintf(verb, c), fmt.Sprintf(verb, *c)) + } + + for _, handler := range []func(*bytes.Buffer) slog.Handler{ + func(b *bytes.Buffer) slog.Handler { return slog.NewTextHandler(b, nil) }, + func(b *bytes.Buffer) slog.Handler { return slog.NewJSONHandler(b, nil) }, + } { + var buf bytes.Buffer + + slog.New(handler(&buf)).Info("rpc", "carrier", c, "value", *c) + printed = append(printed, buf.String()) + } + + for _, out := range printed { + assert.NotContains(t, out, token) + assert.NotContains(t, out, "staged-value") + } + + assert.Equal(t, "grpcsec.Carrier{Incoming: 1 keys, Staged: 1 keys}", c.String()) +} + +// The audit's surviving mutant "invalid credentials tolerated under +// anonymous fallback". +func TestInterceptorsRefuseInvalidCredentialsUnderAnonymousFallback(t *testing.T) { + t.Parallel() + + client := dialBufconn(t, + grpcsec.UnaryServerInterceptor(newEngine(), grpcsec.WithAnonymousFallback(true)), + grpcsec.StreamServerInterceptor(newEngine(), grpcsec.WithAnonymousFallback(true)), + ) + + _, err := client.Check(bearer(context.Background(), "wrong"), &healthpb.HealthCheckRequest{}) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) + + stream, err := client.Watch(bearer(context.Background(), "wrong"), &healthpb.HealthCheckRequest{}) + require.NoError(t, err) + + _, err = stream.Recv() + assert.Equal(t, codes.Unauthenticated, status.Code(err)) +} + +// Audit CORE-I5 (probe TestZZAuditMultipleAuthorizationValues): the first of +// several authorization values silently won. +func TestInterceptorRefusesARepeatedAuthorizationField(t *testing.T) { + t.Parallel() + + client := dialBufconn(t, grpcsec.UnaryServerInterceptor(newEngine()), nil) + + for _, pair := range [][2]string{{"letmein", "garbage"}, {"garbage", "letmein"}} { + ctx := metadata.AppendToOutgoingContext(context.Background(), + "authorization", "Bearer "+pair[0], "authorization", "Bearer "+pair[1]) + + _, err := client.Check(ctx, &healthpb.HealthCheckRequest{}) + assert.Equal(t, codes.InvalidArgument, status.Code(err), "%v", pair) + } +} + +// Audit CORE-I3: a custom Engine breaking its contract must not panic the +// server (gRPC has no recovery interceptor by default). +func TestInterceptorsFailClosedOnABrokenEngine(t *testing.T) { + t.Parallel() + + client := dialBufconn(t, + grpcsec.UnaryServerInterceptor(brokenEngine{}), + grpcsec.StreamServerInterceptor(brokenEngine{}, grpcsec.WithAnonymousFallback(true)), + ) + + _, err := client.Check(bearer(context.Background(), "letmein"), &healthpb.HealthCheckRequest{}) + assert.Equal(t, codes.Internal, status.Code(err)) + + stream, err := client.Watch(bearer(context.Background(), "letmein"), &healthpb.HealthCheckRequest{}) + require.NoError(t, err) + + _, err = stream.Recv() + assert.Equal(t, codes.Internal, status.Code(err)) +} + +// recordingMapperFor answers every error with a fixed status and records +// that it was used. +type fixedMapper struct{ code codes.Code } + +func (m fixedMapper) Map(context.Context, error) error { return status.Error(m.code, "custom") } + +// The authorization interceptors answer with the authentication +// interceptor's mapper unless they were given their own. +func TestAuthorizeUsesTheAuthenticationInterceptorsMapper(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) + attrs := []security.Attribute{security.Role("ADMIN")} + custom := grpcsec.WithErrorMapper(fixedMapper{code: codes.Aborted}) + + inherited := dialWithOptions(t, slices.Concat( + grpcsec.ServerOptions(newEngine("ROLE_USER"), custom), + grpcsec.AuthorizeServerOptions(adm, attrs), + )...) + + _, err := inherited.Check(bearer(context.Background(), "letmein"), &healthpb.HealthCheckRequest{}) + assert.Equal(t, codes.Aborted, status.Code(err)) + + own := dialWithOptions(t, slices.Concat( + grpcsec.ServerOptions(newEngine("ROLE_USER"), custom), + grpcsec.AuthorizeServerOptions(adm, attrs, grpcsec.WithErrorMapper(fixedMapper{code: codes.DataLoss})), + )...) + + _, err = own.Check(bearer(context.Background(), "letmein"), &healthpb.HealthCheckRequest{}) + assert.Equal(t, codes.DataLoss, status.Code(err)) +} + +// Audit CORE-05: the documented wiring installed only unary interceptors, +// so streaming RPCs ran unauthenticated. ServerOptions and +// AuthorizeServerOptions cover both RPC kinds. +func TestServerOptionsProtectUnaryAndStreamingRPCs(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasScope("watch")) + attrs := []security.Attribute{security.Scope("watch")} + + client := func(authorities ...string) healthpb.HealthClient { + return dialWithOptions(t, slices.Concat( + grpcsec.ServerOptions(newEngine(authorities...)), + grpcsec.AuthorizeServerOptions(adm, attrs), + )...) + } + + watch := func(c healthpb.HealthClient, ctx context.Context) codes.Code { + stream, err := c.Watch(ctx, &healthpb.HealthCheckRequest{}) + require.NoError(t, err) + + _, err = stream.Recv() + + return status.Code(err) + } + + check := func(c healthpb.HealthClient, ctx context.Context) codes.Code { + _, err := c.Check(ctx, &healthpb.HealthCheckRequest{}) + + return status.Code(err) + } + + granted := client("scope:watch") + denied := client("scope:other") + + assert.Equal(t, codes.Unauthenticated, check(granted, context.Background())) + assert.Equal(t, codes.Unauthenticated, watch(granted, context.Background())) + assert.Equal(t, codes.PermissionDenied, check(denied, bearer(context.Background(), "letmein"))) + assert.Equal(t, codes.PermissionDenied, watch(denied, bearer(context.Background(), "letmein"))) + assert.Equal(t, codes.OK, check(granted, bearer(context.Background(), "letmein"))) + assert.Equal(t, codes.OK, watch(granted, bearer(context.Background(), "letmein"))) +} + +func TestAuthorizeRefusesANilDecisionManager(t *testing.T) { + t.Parallel() + + const want = "grpcsec: nil AccessDecisionManager" + + assert.PanicsWithValue(t, want, func() { grpcsec.UnaryAuthorize(nil, nil) }) + assert.PanicsWithValue(t, want, func() { grpcsec.StreamAuthorize(nil, nil) }) + assert.PanicsWithValue(t, want, func() { grpcsec.AuthorizeServerOptions(nil, nil) }) +} + +func TestAuthorizeKeepsANonAccessErrorOfACustomManager(t *testing.T) { + t.Parallel() + + outage := scriptedADM{err: fmt.Errorf("policy store: %w", security.ErrUnavailable)} + bug := scriptedADM{err: errors.New("policy bug")} + + for want, adm := range map[codes.Code]security.AccessDecisionManager{codes.Unavailable: outage, codes.Internal: bug} { + client := dialBufconn(t, grpcsec.UnaryAuthorize(adm, nil), nil) + + _, err := client.Check(context.Background(), &healthpb.HealthCheckRequest{}) + assert.Equal(t, want, status.Code(err)) + } +} + +// scriptedADM forces a decision error. +type scriptedADM struct{ err error } + +func (s scriptedADM) Decide(context.Context, security.Authentication, []security.Attribute) error { + return s.err +} diff --git a/grpc/interceptor.go b/grpc/interceptor.go index f233b973..94620252 100644 --- a/grpc/interceptor.go +++ b/grpc/interceptor.go @@ -17,9 +17,14 @@ import ( const tracerName = "github.com/hyperscale-stack/security/grpc" +// errEngineContract reports a custom [security.Engine] that returned no +// context or no authentication without an error: an internal error. +var errEngineContract = errors.New("grpcsec: the engine returned no context or authentication") + // authenticate runs the engine against the RPC metadata and returns the -// enriched context. It is shared by the unary and stream interceptors. -func authenticate(ctx context.Context, engine security.Engine, cfg *config, method string) (context.Context, error) { +// enriched context with the carrier, whose staged writes the caller sends. +// It is shared by the unary and stream interceptors. +func authenticate(ctx context.Context, engine security.Engine, cfg *config, method string) (context.Context, *Carrier, error) { ctx, span := otel.Tracer(tracerName).Start(ctx, "grpcsec.Authenticate") defer span.End() @@ -28,35 +33,41 @@ func authenticate(ctx context.Context, engine security.Engine, cfg *config, meth carrier := NewCarrier(ctx) newCtx, auth, err := engine.Process(ctx, carrier) - if err != nil { - // "no extractor configured" is tolerated only when the caller - // opted into anonymous fallback; every other error is fatal. - tolerated := cfg.anonymousFallback && errors.Is(err, security.ErrNoExtractor) - if !tolerated { - return ctx, fmt.Errorf("grpcsec: authenticate: %w", err) - } - } - if !auth.IsAuthenticated() && !cfg.anonymousFallback { - return ctx, security.ErrInvalidCredentials + // "no extractor configured" is tolerated only when the caller opted into + // anonymous fallback; every other error is fatal. + tolerated := cfg.anonymousFallback && errors.Is(err, security.ErrNoExtractor) + + switch { + case err != nil && !tolerated: + return ctx, carrier, fmt.Errorf("grpcsec: authenticate: %w", err) + case newCtx == nil || auth == nil: + return ctx, carrier, errEngineContract + case !auth.IsAuthenticated() && !cfg.anonymousFallback: + return ctx, carrier, security.ErrAuthenticationRequired } span.SetAttributes(attribute.Bool("security.authenticated", auth.IsAuthenticated())) - return newCtx, nil + return newCtx, carrier, nil } // UnaryServerInterceptor authenticates every unary RPC. On success the // handler runs with the request context enriched via // [security.WithAuthentication]; on failure the configured [ErrorMapper] // turns the security error into a gRPC status error and the handler is -// not invoked. +// not invoked. The metadata the extractors stage on the [Carrier] is sent +// as the response header, refused calls included. +// +// Install [StreamServerInterceptor] too — or use [ServerOptions]: this +// interceptor leaves streaming RPCs unauthenticated. // // It opens a "grpcsec.Authenticate" span but deliberately does NOT open an // "rpc" span — that belongs to otelgrpc, which users compose alongside // this interceptor. func UnaryServerInterceptor(engine security.Engine, opts ...Option) grpc.UnaryServerInterceptor { cfg := buildConfig(opts...) + mapper := cfg.ownMapper() return func( ctx context.Context, @@ -64,20 +75,29 @@ func UnaryServerInterceptor(engine security.Engine, opts ...Option) grpc.UnarySe info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, ) (any, error) { - newCtx, err := authenticate(ctx, engine, cfg, info.FullMethod) + newCtx, carrier, err := authenticate(ctx, engine, cfg, info.FullMethod) + + if md := carrier.ResponseMetadata(); md.Len() > 0 { + // Sent with the response, or before the status of a refused + // call. SetHeader only fails outside a gRPC server. + _ = grpc.SetHeader(ctx, md) + } + if err != nil { - return nil, cfg.errorMapper.Map(ctx, err) + return nil, mapper.Map(ctx, err) } - return handler(newCtx, req) + return handler(withErrorMapper(newCtx, mapper), req) } } // StreamServerInterceptor is the streaming counterpart of // [UnaryServerInterceptor]. The wrapped stream exposes the enriched -// context through ServerStream.Context(). +// context through ServerStream.Context(), and the staged metadata is sent +// as the stream's header. func StreamServerInterceptor(engine security.Engine, opts ...Option) grpc.StreamServerInterceptor { cfg := buildConfig(opts...) + mapper := cfg.ownMapper() return func( srv any, @@ -85,12 +105,19 @@ func StreamServerInterceptor(engine security.Engine, opts ...Option) grpc.Stream info *grpc.StreamServerInfo, handler grpc.StreamHandler, ) error { - newCtx, err := authenticate(ss.Context(), engine, cfg, info.FullMethod) + newCtx, carrier, err := authenticate(ss.Context(), engine, cfg, info.FullMethod) + + if md := carrier.ResponseMetadata(); md.Len() > 0 { + // Sent before the first message, or before the status of a + // refused stream. + _ = ss.SetHeader(md) + } + if err != nil { - return cfg.errorMapper.Map(ss.Context(), err) + return mapper.Map(ss.Context(), err) } - return handler(srv, &wrappedStream{ServerStream: ss, ctx: newCtx}) + return handler(srv, &wrappedStream{ServerStream: ss, ctx: withErrorMapper(newCtx, mapper)}) } } diff --git a/grpc/options.go b/grpc/options.go index dc565cd9..225773e5 100644 --- a/grpc/options.go +++ b/grpc/options.go @@ -4,9 +4,12 @@ package grpcsec +import "context" + // config is the consolidated interceptor configuration, built from the // applied [Option] values. type config struct { + // errorMapper is the mapper given with WithErrorMapper, nil otherwise. errorMapper ErrorMapper anonymousFallback bool } @@ -15,7 +18,9 @@ type config struct { type Option func(*config) // WithErrorMapper overrides the [ErrorMapper] used to translate security -// errors into gRPC status errors. Defaults to [DefaultErrorMapper]. +// errors into gRPC status errors. Without it the authentication +// interceptors use [DefaultErrorMapper], and the authorization interceptors +// use the mapper of the authentication interceptor that ran before them. func WithErrorMapper(m ErrorMapper) Option { return func(c *config) { if m != nil { @@ -28,17 +33,49 @@ func WithErrorMapper(m ErrorMapper) Option { // credential. With true, the RPC proceeds carrying the anonymous // [security.Authentication] and downstream authorisation interceptors are // responsible for rejecting it. Default: false (reject with -// codes.Unauthenticated immediately). +// codes.Unauthenticated immediately). Invalid credentials are refused +// whatever the fallback. func WithAnonymousFallback(allow bool) Option { return func(c *config) { c.anonymousFallback = allow } } // buildConfig applies opts onto the default config. func buildConfig(opts ...Option) *config { - cfg := &config{errorMapper: DefaultErrorMapper()} + cfg := &config{} for _, o := range opts { o(cfg) } return cfg } + +// ownMapper returns the configured mapper, or the default one. +func (c *config) ownMapper() ErrorMapper { + if c.errorMapper != nil { + return c.errorMapper + } + + return DefaultErrorMapper() +} + +// mapper returns the configured mapper, else the one the authentication +// interceptor published in ctx, else the default one. +func (c *config) mapper(ctx context.Context) ErrorMapper { + if c.errorMapper != nil { + return c.errorMapper + } + + if m, ok := ctx.Value(errorMapperKey{}).(ErrorMapper); ok && m != nil { + return m + } + + return DefaultErrorMapper() +} + +// errorMapperKey is the context key under which the authentication +// interceptors publish their error mapper for the authorization ones. +type errorMapperKey struct{} + +func withErrorMapper(ctx context.Context, m ErrorMapper) context.Context { + return context.WithValue(ctx, errorMapperKey{}, m) +} diff --git a/grpc/server_options.go b/grpc/server_options.go new file mode 100644 index 00000000..60e898ee --- /dev/null +++ b/grpc/server_options.go @@ -0,0 +1,45 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package grpcsec + +import ( + "github.com/hyperscale-stack/security" + "google.golang.org/grpc" +) + +// ServerOptions returns the grpc.ServerOption values that authenticate every +// RPC against engine — unary AND streaming. Chaining only +// [UnaryServerInterceptor] leaves the streaming RPCs (the health service's +// Watch, server reflection, your own streams) unauthenticated. +func ServerOptions(engine security.Engine, opts ...Option) []grpc.ServerOption { + return []grpc.ServerOption{ + grpc.ChainUnaryInterceptor(UnaryServerInterceptor(engine, opts...)), + grpc.ChainStreamInterceptor(StreamServerInterceptor(engine, opts...)), + } +} + +// AuthorizeServerOptions returns the grpc.ServerOption values that enforce +// adm against attrs on every RPC — unary AND streaming. Pass them after +// [ServerOptions]: chained interceptors run in the order of the options, so +// the authentication runs first. +// +// srv := grpc.NewServer(slices.Concat( +// grpcsec.ServerOptions(engine), +// grpcsec.AuthorizeServerOptions(adm, attrs), +// )...) +// +// It panics if adm is nil. +func AuthorizeServerOptions( + adm security.AccessDecisionManager, + attrs []security.Attribute, + opts ...Option, +) []grpc.ServerOption { + mustDecisionManager(adm) + + return []grpc.ServerOption{ + grpc.ChainUnaryInterceptor(UnaryAuthorize(adm, attrs, opts...)), + grpc.ChainStreamInterceptor(StreamAuthorize(adm, attrs, opts...)), + } +} diff --git a/grpc/testing_helpers_test.go b/grpc/testing_helpers_test.go index 64fb8ece..9d9eec12 100644 --- a/grpc/testing_helpers_test.go +++ b/grpc/testing_helpers_test.go @@ -32,8 +32,6 @@ func dialBufconn( ) healthpb.HealthClient { t.Helper() - lis := bufconn.Listen(1 << 20) - var serverOpts []grpc.ServerOption if unary != nil { serverOpts = append(serverOpts, grpc.UnaryInterceptor(unary)) @@ -43,6 +41,15 @@ func dialBufconn( serverOpts = append(serverOpts, grpc.StreamInterceptor(stream)) } + return dialWithOptions(t, serverOpts...) +} + +// dialWithOptions is dialBufconn for a server built from server options. +func dialWithOptions(t *testing.T, serverOpts ...grpc.ServerOption) healthpb.HealthClient { + t.Helper() + + lis := bufconn.Listen(1 << 20) + srv := grpc.NewServer(serverOpts...) healthpb.RegisterHealthServer(srv, health.NewServer()) @@ -93,7 +100,11 @@ func (a fakeAuth) Name() string { return a.pr.Subject() } type tokenExtractor struct{} func (tokenExtractor) Extract(_ context.Context, c security.Carrier) (security.Authentication, error) { - v := c.Get("authorization") + v, err := security.SingleValue(c, "authorization") + if err != nil { + return nil, err //nolint:wrapcheck // test double + } + if v == "" { return nil, nil } From e03104cc3d8404aae2eb6269a449bc3cc0594f83 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 00:46:40 +0200 Subject: [PATCH 09/29] fix(connectrpc): challenges on 401, generic codes, redacted carrier A ConnectRPC unary call refused with Unauthenticated travels as an HTTP 401, which MUST carry a WWW-Authenticate challenge (RFC 9110 section 11.6.1); the Connect error mapper sent none. Its messages also told the client why authentication failed, an infrastructure failure read as Unauthenticated, and an unsupported credential as InvalidArgument. connectrpcsec.DefaultErrorMapper(scheme, realm) answers from security.ErrorClass with one fixed message per code, like the gRPC mapper, and puts the RFC 6750 / RFC 7617 challenge of the HTTP adapter in the error's metadata, sent as response headers for unary calls: realm always present, charset="UTF-8" for Basic, invalid_token, insufficient_scope and invalid_request only for requests that carried a bearer token. WithChallengeScheme and WithRealm configure it; the authorization interceptor uses the authentication interceptor's mapper unless given its own, and asks an unauthenticated principal to authenticate. The challenge helpers are a twin of httpsec's, the dependency policy keeping the module off httpsec. Printing a connectrpcsec.Carrier printed its incoming headers, the bearer token included; it now prints and logs key counts only. Headers staged on the carrier reach the client on a refusal and on a handler error too, and a custom Engine returning no authentication no longer panics the interceptors. The connectrpc-bearer example pins the HTTP behaviour, the query string included (decision 6). Audit: CORE-10, CORE-I6, CORE-15, CORE-I4, CORE-I3, CORE-02. --- CHANGELOG.md | 21 ++ MIGRATION.md | 27 ++ connectrpc/authorize.go | 34 ++- connectrpc/authorize_test.go | 5 +- connectrpc/carrier.go | 34 ++- connectrpc/challenge.go | 93 +++++++ connectrpc/error_mapper.go | 156 ++++++++--- connectrpc/error_mapper_test.go | 81 ++++-- connectrpc/example_test.go | 6 +- connectrpc/hardening_test.go | 268 +++++++++++++++++++ connectrpc/interceptor.go | 66 +++-- connectrpc/interceptor_test.go | 2 +- connectrpc/options.go | 76 +++++- connectrpc/testing_helpers_test.go | 6 +- docs/architecture.md | 3 +- docs/security-considerations.md | 7 +- examples/connectrpc-bearer/challenge_test.go | 89 ++++++ 17 files changed, 876 insertions(+), 98 deletions(-) create mode 100644 connectrpc/challenge.go create mode 100644 connectrpc/hardening_test.go create mode 100644 examples/connectrpc-bearer/challenge_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 777974e3..16bc76d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -457,6 +457,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - `grpcsec.ServerOptions` and `grpcsec.AuthorizeServerOptions`: the authentication and authorization interceptors for unary AND streaming RPCs, as `grpc.ServerOption` values. +- `connectrpcsec.WithChallengeScheme`, `connectrpcsec.WithRealm` and + `connectrpcsec.DefaultRealm`: the challenge of the Connect error mapping. ### Changed @@ -683,6 +685,16 @@ legacy packages (`authentication/`, `authorization/`, the in-tree an `ErrUnavailable` `Unavailable`, and an error wrapping no sentinel `Internal` (was `Unauthenticated`). The authorization interceptors use the authentication interceptor's error mapper unless given their own. +- `connectrpcsec.DefaultErrorMapper()` becomes + `DefaultErrorMapper(scheme, realm string)` and answers from + `security.ErrorClass` like the gRPC mapper (generic messages, + `Unauthenticated` for an unsupported credential and for an anonymous + principal refused by the authorization interceptor, `InvalidArgument` for + a repeated `Authorization` field, `Unavailable` for `ErrUnavailable`, + `Internal` for an error wrapping no sentinel), with the RFC 6750 / RFC + 7617 `WWW-Authenticate` challenge in the error's metadata. The + authorization interceptor uses the authentication interceptor's error + mapper unless given its own. - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -804,6 +816,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree header, refused RPCs included, as its documentation said; it was dropped. A custom `Engine` returning no authentication no longer panics the interceptors. +- A ConnectRPC unary call refused with `Unauthenticated` (HTTP 401) carried + no `WWW-Authenticate` field (RFC 9110 §11.6.1); it now carries the + challenge. Headers staged on the `connectrpcsec.Carrier` reach the client + on a refused call and on a handler error too, and a custom `Engine` + returning no authentication no longer panics the interceptors. - The SAML Redis store answered `ErrTransactionExists` only when a second save differed from the stored transaction: an identical one succeeded. It @@ -1269,3 +1286,7 @@ legacy packages (`authentication/`, `authorization/`, the in-tree code. `grpcsec.Carrier` printed its incoming metadata — the bearer token in `authorization` — with `%v`; it now prints and logs key counts only, whatever the verb. +- `connectrpcsec.Carrier` printed its incoming headers — the bearer token + in `Authorization` — with `%v`, and the Connect error messages told the + client why authentication failed; the carrier now prints and logs key + counts only, and the messages are one fixed string per code. diff --git a/MIGRATION.md b/MIGRATION.md index 8b1c47b7..e3dea7e1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1485,3 +1485,30 @@ To migrate: 3. Authenticators: wrap `security.ErrUnavailable` only for a transient dependency failure (clients retry `Unavailable`), a credential sentinel for a refusal. + +### `connectrpc`: challenges on 401, generic codes and messages + +A ConnectRPC unary call refused with `Unauthenticated` is an HTTP 401, which +MUST carry a `WWW-Authenticate` challenge (RFC 9110 §11.6.1); none was sent. +The Connect error messages also told the client why authentication failed, +and infrastructure failures read as `Unauthenticated`. The default mapper now +takes the challenge configuration and answers like the gRPC one: + +| Situation | Before | After | +| --- | --- | --- | +| `DefaultErrorMapper()` | no argument | `DefaultErrorMapper(scheme, realm string)` (`""` → `Bearer`, `DefaultRealm`) | +| Unauthenticated / PermissionDenied | no `WWW-Authenticate` | challenge in the error metadata (HTTP headers for unary calls) | +| Credential failure | reason in the message | `unauthenticated` | +| Unsupported credential | `InvalidArgument` | `Unauthenticated` | +| Anonymous caller refused by the authorization interceptor | `PermissionDenied` | `Unauthenticated` | +| Repeated `Authorization` field | first value used | `InvalidArgument` | +| `ErrUnavailable` / error wrapping no sentinel | `Unauthenticated` | `Unavailable` / `Internal` | +| Headers staged on the `Carrier`, refused call | dropped | in the error metadata | + +To migrate: + +1. Replace `connectrpcsec.DefaultErrorMapper()` by + `connectrpcsec.DefaultErrorMapper("Bearer", connectrpcsec.DefaultRealm)` + (or your scheme and realm), or configure the interceptors with + `connectrpcsec.WithChallengeScheme` / `connectrpcsec.WithRealm`. +2. Branch on the code, never on the message. diff --git a/connectrpc/authorize.go b/connectrpc/authorize.go index 26ae4136..8d40937c 100644 --- a/connectrpc/authorize.go +++ b/connectrpc/authorize.go @@ -6,6 +6,8 @@ package connectrpcsec import ( "context" + "errors" + "fmt" "connectrpc.com/connect" "github.com/hyperscale-stack/security" @@ -22,8 +24,11 @@ import ( // connect.WithInterceptors(authn, authz) runs authn (which enriches the // context) before authz. // -// On grant the handler runs; on deny the configured [ErrorMapper] translates -// the decision (typically connect.CodePermissionDenied). +// On grant the handler runs. On deny it answers with its [WithErrorMapper] +// (or challenge options), else the mapper of the authentication interceptor +// before it, else [DefaultErrorMapper]: connect.CodePermissionDenied for an +// authenticated principal, connect.CodeUnauthenticated — with a challenge — +// for an unauthenticated one. type AuthorizationInterceptor struct { adm security.AccessDecisionManager attrs []security.Attribute @@ -31,12 +36,17 @@ type AuthorizationInterceptor struct { } // NewAuthorizationInterceptor builds a [connect.Interceptor] that enforces adm -// against attrs for every inbound unary and streaming RPC. +// against attrs for every inbound unary and streaming RPC. It panics if adm +// is nil. func NewAuthorizationInterceptor( adm security.AccessDecisionManager, attrs []security.Attribute, opts ...Option, ) *AuthorizationInterceptor { + if adm == nil { + panic("connectrpcsec: NewAuthorizationInterceptor: nil AccessDecisionManager") + } + return &AuthorizationInterceptor{adm: adm, attrs: attrs, cfg: buildConfig(opts...)} } @@ -52,7 +62,7 @@ func (i *AuthorizationInterceptor) WrapUnary(next connect.UnaryFunc) connect.Una } if err := decide(ctx, i.adm, i.attrs); err != nil { - return nil, i.cfg.errorMapper.Map(ctx, err) + return nil, i.cfg.mapper(ctx).Map(withBearerToken(ctx, req.Header()), err) } return next(ctx, req) //nolint:wrapcheck // the handler / connect error is the terminal wire value @@ -66,7 +76,7 @@ func (i *AuthorizationInterceptor) WrapStreamingHandler( ) connect.StreamingHandlerFunc { return func(ctx context.Context, conn connect.StreamingHandlerConn) error { if err := decide(ctx, i.adm, i.attrs); err != nil { - return i.cfg.errorMapper.Map(ctx, err) + return i.cfg.mapper(ctx).Map(withBearerToken(ctx, conn.RequestHeader()), err) } return next(ctx, conn) //nolint:wrapcheck // the handler error is the terminal wire value @@ -90,8 +100,20 @@ func decide(ctx context.Context, adm security.AccessDecisionManager, attrs []sec auth, _ := security.FromContext(ctx) if err := adm.Decide(ctx, auth, attrs); err != nil { - return err //nolint:wrapcheck // security.* sentinels pass through to the ErrorMapper + return refusal(auth, err) } return nil } + +// refusal turns an access denial of an unauthenticated principal into +// "authentication required" (connect.CodeUnauthenticated): the caller lacks +// credentials. Any other error passes through. +func refusal(auth security.Authentication, err error) error { + denied := errors.Is(err, security.ErrAccessDenied) || errors.Is(err, security.ErrInsufficientScope) + if denied && !auth.IsAuthenticated() { + return fmt.Errorf("%w: %w", security.ErrAuthenticationRequired, err) + } + + return err //nolint:wrapcheck // security.* sentinels pass through to the ErrorMapper +} diff --git a/connectrpc/authorize_test.go b/connectrpc/authorize_test.go index 73f84ba2..a0405349 100644 --- a/connectrpc/authorize_test.go +++ b/connectrpc/authorize_test.go @@ -70,12 +70,13 @@ func TestUnaryAuthorizeDeniesAnonymous(t *testing.T) { t.Parallel() spy := &recordingUnary{} - // No authentication in the context: the voter denies the anonymous caller. + // No authentication in the context: the voter denies the anonymous + // caller, which is asked to authenticate. wrapped := connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs).WrapUnary(spy.fn) _, err := wrapped(context.Background(), unaryReq("letmein")) require.Error(t, err) - assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err)) + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) assert.False(t, spy.called) } diff --git a/connectrpc/carrier.go b/connectrpc/carrier.go index 5360b75f..dbefdda6 100644 --- a/connectrpc/carrier.go +++ b/connectrpc/carrier.go @@ -5,6 +5,9 @@ package connectrpcsec import ( + "fmt" + "io" + "log/slog" "net/http" "github.com/hyperscale-stack/security" @@ -19,9 +22,14 @@ import ( // // Writes accumulate in a private staged header that the interceptor flushes // onto the live response header once one is available — immediately for a -// streaming handler (conn.ResponseHeader() is live) and after the handler -// runs for a unary call (the response Header()). This lets an ErrorMapper or -// an extractor attach, e.g., a diagnostic header alongside the response. +// streaming handler (conn.ResponseHeader() is live), after the handler runs +// for a unary call (the response Header()), and into the error's metadata +// for a refused call or a handler error. This lets an extractor attach, +// e.g., a diagnostic header alongside the response. +// +// A Carrier prints and logs its key counts only, never the header values. +// It does not implement the session module's cookie accessor: a cookie +// session does not authenticate ConnectRPC calls. // // Carrier is NOT safe for concurrent use; one instance per RPC. type Carrier struct { @@ -67,3 +75,23 @@ func (c *Carrier) ResponseHeader() http.Header { return c.out } // Compile-time check. var _ security.Carrier = (*Carrier)(nil) + +// String implements fmt.Stringer with the number of incoming and staged +// header keys: the values — the Authorization header among them — are never +// printed. +func (c Carrier) String() string { + return fmt.Sprintf("connectrpcsec.Carrier{Incoming: %d keys, Staged: %d keys}", len(c.in), len(c.out)) +} + +// GoString implements fmt.GoStringer like String. +func (c Carrier) GoString() string { return c.String() } + +// Format implements fmt.Formatter like String, for every verb: without it, +// fmt prints the header maps. fmt handles %p of a non-pointer value before +// any method: no type can redact that one. +func (c Carrier) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, c.String()) } + +// LogValue implements slog.LogValuer with the key counts only. +func (c Carrier) LogValue() slog.Value { + return slog.GroupValue(slog.Int("incoming_keys", len(c.in)), slog.Int("staged_keys", len(c.out))) +} diff --git a/connectrpc/challenge.go b/connectrpc/challenge.go new file mode 100644 index 00000000..13fa0dcf --- /dev/null +++ b/connectrpc/challenge.go @@ -0,0 +1,93 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec + +import ( + "context" + "net/http" + "strings" +) + +// The twin of the formatting helpers below lives in http/challenge.go: the +// dependency policy keeps this module off httpsec, and both are pinned by +// the same table tests. + +// isToken reports whether s is an RFC 9110 §5.6.2 token — the syntax of an +// auth-scheme. +func isToken(s string) bool { + if s == "" { + return false + } + + for i := range len(s) { + c := s[i] + + switch { + case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9': + case strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0: + default: + return false + } + } + + return true +} + +// quoteString renders s as an RFC 9110 §5.6.4 quoted-string: DQUOTE and +// backslash become a quoted-pair, HTAB, SP and visible ASCII stay as they +// are, and anything else — control characters (CR, LF, NUL included, +// RFC 9110 §5.5), DEL, non-ASCII — makes ok false. +func quoteString(s string) (string, bool) { + var b strings.Builder + + b.Grow(len(s) + 2) + b.WriteByte('"') + + for i := range len(s) { + c := s[i] + + switch { + case c == '"' || c == '\\': + b.WriteByte('\\') + b.WriteByte(c) + case c == '\t' || (c >= 0x20 && c <= 0x7e): + b.WriteByte(c) + default: + return "", false + } + } + + b.WriteByte('"') + + return b.String(), true +} + +// bearerTokenKey is the context key under which the interceptors tell the +// default mapper whether the request carried a bearer token. +type bearerTokenKey struct{} + +// withBearerToken records in ctx whether header carries an Authorization +// field with the Bearer scheme (case-insensitive, RFC 9110 §11.1): the +// RFC 6750 error codes describe why a request made with an access token was +// refused, so they are not sent otherwise. +func withBearerToken(ctx context.Context, header http.Header) context.Context { + sent := false + + for _, v := range header.Values("Authorization") { + if scheme, _, _ := strings.Cut(v, " "); strings.EqualFold(scheme, "Bearer") { + sent = true + + break + } + } + + return context.WithValue(ctx, bearerTokenKey{}, sent) +} + +func bearerTokenSent(ctx context.Context) bool { + sent, _ := ctx.Value(bearerTokenKey{}).(bool) + + return sent +} diff --git a/connectrpc/error_mapper.go b/connectrpc/error_mapper.go index 5db71c78..5295a0bb 100644 --- a/connectrpc/error_mapper.go +++ b/connectrpc/error_mapper.go @@ -7,14 +7,25 @@ package connectrpcsec import ( "context" "errors" + "fmt" + "strings" "connectrpc.com/connect" "github.com/hyperscale-stack/security" ) +// defaultChallengeScheme is the WWW-Authenticate scheme used when none is +// configured — RFC 6750 bearer tokens. +const defaultChallengeScheme = "Bearer" + +// DefaultRealm is the realm of the default challenges when none is +// configured: RFC 7617 §2 requires one in a Basic challenge, and RFC 6750 §3 +// requires an auth-param after Bearer. +const DefaultRealm = "api" + // ErrorMapper translates a security error into a Connect error. Custom -// mappers can localize messages or attach metadata; the default mapper covers -// the canonical security sentinels. +// mappers can localize messages or attach metadata; they should classify +// the error with [security.ErrorClass], as the default mapper does. // // Implementations MUST be safe for concurrent use. type ErrorMapper interface { @@ -23,52 +34,127 @@ type ErrorMapper interface { Map(ctx context.Context, err error) error } -// DefaultErrorMapper returns the canonical mapper: +// DefaultErrorMapper returns the canonical mapper. It answers from +// [security.ErrorClass](err) with one fixed message per code — the message +// never says why authentication failed — and, as a Connect unary error +// travels as an HTTP response, with the WWW-Authenticate challenge of +// scheme S and realm R in the error's metadata (sent as response headers): // -// - connect.CodeInvalidArgument for [security.ErrUnsupportedCredential] -// - connect.CodePermissionDenied for [security.ErrAccessDenied] and -// [security.ErrInsufficientScope] -// - connect.CodeUnauthenticated for ErrInvalidCredentials, -// ErrClientSecretMismatch, ErrTokenExpired, ErrTokenNotFound, -// ErrAuthenticatorRefused, and any other unclassified error +// class code (HTTP) challenge +// invalid_request InvalidArgument (400) Bearer + bearer token sent: error="invalid_request" +// unavailable Unavailable (503) — +// token_expired, token_not_found, +// client_secret_mismatch, invalid_credentials Unauthenticated (401) S realm=R; Bearer + bearer token sent: error="invalid_token" +// authentication_required, +// unsupported_credential Unauthenticated (401) S realm=R (no error code) +// insufficient_scope, access_denied PermissionDenied (403) S realm=R; Bearer + bearer token sent: error="insufficient_scope" +// no_extractor, _OTHER Internal (500) — // -// The message is intentionally terse — Connect clients branch on the code, -// not the string. -func DefaultErrorMapper() ErrorMapper { return defaultErrorMapper{} } +// A Basic challenge also carries charset="UTF-8" (RFC 7617 §2.1). Whether +// the request carried a bearer token is told by the interceptors; without +// them no RFC 6750 code is sent. An empty scheme means "Bearer" and an +// empty realm [DefaultRealm]. DefaultErrorMapper panics when scheme is not +// an RFC 9110 token or realm holds a character a quoted-string cannot +// carry: a challenge configuration is static, so it fails at startup. +func DefaultErrorMapper(scheme, realm string) ErrorMapper { + if scheme == "" { + scheme = defaultChallengeScheme + } -type defaultErrorMapper struct{} + if realm == "" { + realm = DefaultRealm + } -// Map implements [ErrorMapper]. The returned Connect error is the final wire -// value — not a wrapping of err — so wrapcheck is silenced here. -func (defaultErrorMapper) Map(_ context.Context, err error) error { - code, msg := classify(err) + if !isToken(scheme) { + panic(fmt.Sprintf("connectrpcsec: challenge scheme %q is not an RFC 9110 token", scheme)) + } - return connect.NewError(code, errors.New(msg)) //nolint:wrapcheck // connect error is the terminal wire value + quoted, ok := quoteString(realm) + if !ok { + panic(fmt.Sprintf("connectrpcsec: realm %q is not printable ASCII (RFC 9110 §5.6.4 quoted-string)", realm)) + } + + params := "realm=" + quoted + if strings.EqualFold(scheme, "Basic") { + params += `, charset="UTF-8"` + } + + return &defaultErrorMapper{ + challenge: scheme + " " + params, + bearer: strings.EqualFold(scheme, defaultChallengeScheme), + } } -func classify(err error) (connect.Code, string) { - switch { - case errors.Is(err, security.ErrUnsupportedCredential): - return connect.CodeInvalidArgument, "unsupported credential" +type defaultErrorMapper struct { + // challenge is the scheme followed by its realm (and charset) auth-params. + challenge string + // bearer reports a Bearer challenge, which takes the RFC 6750 codes. + bearer bool +} + +// Map implements [ErrorMapper]. The returned Connect error is the final wire +// value — not a wrapping of err. +func (m *defaultErrorMapper) Map(ctx context.Context, err error) error { + o := outcomeOf(security.ErrorClass(err)) + ce := connect.NewError(o.code, errors.New(o.message)) - case errors.Is(err, security.ErrAccessDenied): - return connect.CodePermissionDenied, "access denied" + if challenge := m.challengeFor(o, bearerTokenSent(ctx)); challenge != "" { + ce.Meta().Set("WWW-Authenticate", challenge) + } + + return ce +} - case errors.Is(err, security.ErrInsufficientScope): - return connect.CodePermissionDenied, "insufficient scope" +// challengeFor returns the WWW-Authenticate value of an outcome, or "". +func (m *defaultErrorMapper) challengeFor(o outcome, bearerSent bool) string { + switch { + case m.bearer && o.oauthCode != "" && bearerSent: + return m.challenge + `, error="` + o.oauthCode + `", error_description="` + o.description + `"` + case o.challenge: + return m.challenge + default: + return "" + } +} - case errors.Is(err, security.ErrTokenExpired): - return connect.CodeUnauthenticated, "token expired" +// outcome is the Connect answer to an error class. +type outcome struct { + code connect.Code + message string + // challenge reports an answer (401, 403) that always carries a challenge. + challenge bool + // oauthCode and description are the RFC 6750 §3.1 error of a Bearer + // challenge, for a request that carried a bearer token. + oauthCode string + description string +} - case errors.Is(err, security.ErrTokenNotFound): - return connect.CodeUnauthenticated, "token not found" +// msgUnauthenticated is the one message of every Unauthenticated answer: it +// never says why authentication failed. +const msgUnauthenticated = "unauthenticated" - case errors.Is(err, security.ErrInvalidCredentials), - errors.Is(err, security.ErrClientSecretMismatch), - errors.Is(err, security.ErrAuthenticatorRefused): - return connect.CodeUnauthenticated, "invalid credentials" +// RFC 6750 §3.1 error_description strings: fixed and generic. +const ( + descInvalidRequest = "The request is malformed." + descInvalidToken = "The access token is invalid or has expired." + descInsufficientScope = "The request requires higher privileges than provided by the access token." +) +// outcomeOf maps a [security.ErrorClass] to its Connect answer. +func outcomeOf(class string) outcome { + switch class { + case security.ErrorClassInvalidRequest: + return outcome{connect.CodeInvalidArgument, "invalid request", false, "invalid_request", descInvalidRequest} + case security.ErrorClassUnavailable: + return outcome{code: connect.CodeUnavailable, message: "authentication unavailable"} + case security.ErrorClassTokenExpired, security.ErrorClassTokenNotFound, + security.ErrorClassClientSecretMismatch, security.ErrorClassInvalidCredentials: + return outcome{connect.CodeUnauthenticated, msgUnauthenticated, true, "invalid_token", descInvalidToken} + case security.ErrorClassAuthenticationRequired, security.ErrorClassUnsupportedCredential: + return outcome{code: connect.CodeUnauthenticated, message: msgUnauthenticated, challenge: true} + case security.ErrorClassInsufficientScope, security.ErrorClassAccessDenied: + return outcome{connect.CodePermissionDenied, "permission denied", true, "insufficient_scope", descInsufficientScope} default: - return connect.CodeUnauthenticated, "unauthenticated" + return outcome{code: connect.CodeInternal, message: "internal error"} } } diff --git a/connectrpc/error_mapper_test.go b/connectrpc/error_mapper_test.go index d36571c8..f9126987 100644 --- a/connectrpc/error_mapper_test.go +++ b/connectrpc/error_mapper_test.go @@ -7,6 +7,7 @@ package connectrpcsec_test import ( "context" "errors" + "fmt" "testing" "connectrpc.com/connect" @@ -16,39 +17,77 @@ import ( "github.com/stretchr/testify/require" ) +const ( + bearerAPI = `Bearer realm="api"` + basicAPI = `Basic realm="api", charset="UTF-8"` +) + +// Audit CORE-10 / CORE-I6: a Connect 401 carried no WWW-Authenticate +// (RFC 9110 §11.6.1), and the messages told the client why authentication +// failed. The mapper itself does not know whether the request carried a +// bearer token — the interceptors tell it (see the interceptor tests). func TestDefaultErrorMapperClassification(t *testing.T) { t.Parallel() + refused := func(causes ...error) error { + return fmt.Errorf("%w: %w", security.ErrAuthenticatorRefused, errors.Join(causes...)) + } + tests := []struct { - name string - err error - want connect.Code + name string + scheme string + err error + code connect.Code + msg string + challenge []string }{ - {"unsupported_credential", security.ErrUnsupportedCredential, connect.CodeInvalidArgument}, - {"access_denied", security.ErrAccessDenied, connect.CodePermissionDenied}, - {"insufficient_scope", security.ErrInsufficientScope, connect.CodePermissionDenied}, - {"token_expired", security.ErrTokenExpired, connect.CodeUnauthenticated}, - {"token_not_found", security.ErrTokenNotFound, connect.CodeUnauthenticated}, - {"invalid_credentials", security.ErrInvalidCredentials, connect.CodeUnauthenticated}, - {"client_secret_mismatch", security.ErrClientSecretMismatch, connect.CodeUnauthenticated}, - {"authenticator_refused", security.ErrAuthenticatorRefused, connect.CodeUnauthenticated}, - {"unknown_error", errors.New("boom"), connect.CodeUnauthenticated}, - { - "wrapped_access_denied", - errors.Join(errors.New("ctx"), security.ErrAccessDenied), - connect.CodePermissionDenied, - }, + {"invalid_request", "", security.ErrInvalidRequest, connect.CodeInvalidArgument, "invalid request", nil}, + {"unavailable", "", security.ErrUnavailable, connect.CodeUnavailable, "authentication unavailable", nil}, + {"unavailable_joined", "", refused(security.ErrTokenNotFound, security.ErrUnavailable), connect.CodeUnavailable, "authentication unavailable", nil}, + {"token_expired", "", security.ErrTokenExpired, connect.CodeUnauthenticated, "unauthenticated", []string{bearerAPI}}, + {"token_not_found", "", security.ErrTokenNotFound, connect.CodeUnauthenticated, "unauthenticated", []string{bearerAPI}}, + {"invalid_credentials", "", security.ErrInvalidCredentials, connect.CodeUnauthenticated, "unauthenticated", []string{bearerAPI}}, + {"client_secret_mismatch", "", security.ErrClientSecretMismatch, connect.CodeUnauthenticated, "unauthenticated", []string{bearerAPI}}, + {"authenticator_refused", "", refused(security.ErrInvalidCredentials), connect.CodeUnauthenticated, "unauthenticated", []string{bearerAPI}}, + {"authentication_required", "", security.ErrAuthenticationRequired, connect.CodeUnauthenticated, "unauthenticated", []string{bearerAPI}}, + {"unsupported_credential", "", security.ErrUnsupportedCredential, connect.CodeUnauthenticated, "unauthenticated", []string{bearerAPI}}, + {"access_denied", "", security.ErrAccessDenied, connect.CodePermissionDenied, "permission denied", []string{bearerAPI}}, + {"insufficient_scope", "", security.ErrInsufficientScope, connect.CodePermissionDenied, "permission denied", []string{bearerAPI}}, + {"no_extractor", "", security.ErrNoExtractor, connect.CodeInternal, "internal error", nil}, + {"no_sentinel", "", errors.New("boom"), connect.CodeInternal, "internal error", nil}, + {"basic", "Basic", security.ErrInvalidCredentials, connect.CodeUnauthenticated, "unauthenticated", []string{basicAPI}}, + {"basic_denied", "Basic", security.ErrAccessDenied, connect.CodePermissionDenied, "permission denied", []string{basicAPI}}, } - mapper := connectrpcsec.DefaultErrorMapper() - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := mapper.Map(context.Background(), tc.err) + got := connectrpcsec.DefaultErrorMapper(tc.scheme, "").Map(context.Background(), tc.err) require.Error(t, got) - assert.Equal(t, tc.want, connect.CodeOf(got)) + + var ce *connect.Error + require.ErrorAs(t, got, &ce) + assert.Equal(t, tc.code, ce.Code()) + assert.Equal(t, tc.msg, ce.Message(), "the message never says why") + assert.Equal(t, tc.challenge, ce.Meta().Values("WWW-Authenticate")) }) } } + +func TestDefaultErrorMapperRefusesAnInvalidChallengeConfiguration(t *testing.T) { + t.Parallel() + + assert.PanicsWithValue(t, `connectrpcsec: challenge scheme "Bea rer" is not an RFC 9110 token`, + func() { connectrpcsec.DefaultErrorMapper("Bea rer", "") }) + assert.PanicsWithValue(t, `connectrpcsec: realm "a\r\nb" is not printable ASCII (RFC 9110 §5.6.4 quoted-string)`, + func() { connectrpcsec.DefaultErrorMapper("", "a\r\nb") }) + assert.PanicsWithValue(t, `connectrpcsec: realm "café" is not printable ASCII (RFC 9110 §5.6.4 quoted-string)`, + func() { connectrpcsec.NewAuthenticationInterceptor(newEngine(), connectrpcsec.WithRealm("café")) }) + + got := connectrpcsec.DefaultErrorMapper("Bearer", `my "api" \ realm`).Map(context.Background(), security.ErrAuthenticationRequired) + + var ce *connect.Error + require.ErrorAs(t, got, &ce) + assert.Equal(t, `Bearer realm="my \"api\" \\ realm"`, ce.Meta().Get("WWW-Authenticate")) +} diff --git a/connectrpc/example_test.go b/connectrpc/example_test.go index 6c2e85ab..55f32b6f 100644 --- a/connectrpc/example_test.go +++ b/connectrpc/example_test.go @@ -36,11 +36,11 @@ func Example() { _ = engine _ = adm - mapper := connectrpcsec.DefaultErrorMapper() + mapper := connectrpcsec.DefaultErrorMapper("Bearer", connectrpcsec.DefaultRealm) for _, err := range []error{ security.ErrInvalidCredentials, security.ErrAccessDenied, - security.ErrUnsupportedCredential, + security.ErrUnavailable, } { fmt.Println(connect.CodeOf(mapper.Map(context.Background(), err))) } @@ -48,5 +48,5 @@ func Example() { // Output: // unauthenticated // permission_denied - // invalid_argument + // unavailable } diff --git a/connectrpc/hardening_test.go b/connectrpc/hardening_test.go new file mode 100644 index 00000000..e359a301 --- /dev/null +++ b/connectrpc/hardening_test.go @@ -0,0 +1,268 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "testing" + + "connectrpc.com/connect" + "github.com/hyperscale-stack/security" + connectrpcsec "github.com/hyperscale-stack/security/connectrpc" + "github.com/hyperscale-stack/security/voter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + invalidToken = `Bearer realm="api", error="invalid_token", error_description="The access token is invalid or has expired."` + insufficient = `Bearer realm="api", error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token."` + invalidRequest = `Bearer realm="api", error="invalid_request", error_description="The request is malformed."` +) + +// brokenEngine breaks the Engine contract. +type brokenEngine struct{} + +func (brokenEngine) Process(context.Context, security.Carrier) (context.Context, security.Authentication, error) { + return nil, nil, nil +} + +func connectError(t *testing.T, err error) *connect.Error { + t.Helper() + + var ce *connect.Error + require.ErrorAs(t, err, &ce) + + return ce +} + +func requestWith(authorization ...string) connect.AnyRequest { + req := connect.NewRequest(&struct{}{}) + for _, v := range authorization { + req.Header().Add("Authorization", v) + } + + return req +} + +// Audit CORE-10: the RFC 6750 codes ride on the challenge of a request that +// carried a bearer token, and only then. +func TestWrapUnaryChallenges(t *testing.T) { + t.Parallel() + + spy := &recordingUnary{} + authn := connectrpcsec.NewAuthenticationInterceptor(newEngine()) + + cases := []struct { + name string + req connect.AnyRequest + code connect.Code + challenge []string + }{ + {"no token", requestWith(), connect.CodeUnauthenticated, []string{bearerAPI}}, + {"bad token", requestWith("Bearer wrong"), connect.CodeUnauthenticated, []string{invalidToken}}, + {"repeated authorization", requestWith("Bearer letmein", "Bearer wrong"), connect.CodeInvalidArgument, []string{invalidRequest}}, + {"another scheme", requestWith("Basic dTpw"), connect.CodeUnauthenticated, []string{bearerAPI}}, + } + + for _, c := range cases { + _, err := authn.WrapUnary(spy.fn)(context.Background(), c.req) + + ce := connectError(t, err) + assert.Equal(t, c.code, ce.Code(), c.name) + assert.Equal(t, c.challenge, ce.Meta().Values("WWW-Authenticate"), c.name) + } + + assert.False(t, spy.called) +} + +func TestAuthorizationChallenges(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) + attrs := []security.Attribute{security.Role("ADMIN")} + + // Authenticated USER with a bearer token: 403 insufficient_scope. + spy := &recordingUnary{} + _, err := chainUnary( + connectrpcsec.NewAuthenticationInterceptor(newEngine("ROLE_USER")), + connectrpcsec.NewAuthorizationInterceptor(adm, attrs), + spy.fn, + )(context.Background(), unaryReq("letmein")) + + ce := connectError(t, err) + assert.Equal(t, connect.CodePermissionDenied, ce.Code()) + assert.Equal(t, []string{insufficient}, ce.Meta().Values("WWW-Authenticate")) + + // Anonymous (fallback): asked to authenticate, with the authentication + // interceptor's challenge. + _, err = chainUnary( + connectrpcsec.NewAuthenticationInterceptor(newEngine(), connectrpcsec.WithAnonymousFallback(true), + connectrpcsec.WithChallengeScheme("Basic")), + connectrpcsec.NewAuthorizationInterceptor(adm, attrs), + spy.fn, + )(context.Background(), unaryReq("")) + + ce = connectError(t, err) + assert.Equal(t, connect.CodeUnauthenticated, ce.Code()) + assert.Equal(t, []string{basicAPI}, ce.Meta().Values("WWW-Authenticate")) + + // Streaming refusal carries the challenge too. + stream := &recordingStream{} + err = connectrpcsec.NewAuthenticationInterceptor(newEngine()).WrapStreamingHandler(stream.fn)( + context.Background(), newStreamConn(bearerHeader("wrong"))) + + ce = connectError(t, err) + assert.Equal(t, connect.CodeUnauthenticated, ce.Code()) + assert.Equal(t, []string{invalidToken}, ce.Meta().Values("WWW-Authenticate")) + assert.False(t, spy.called) + assert.False(t, stream.called) +} + +// Staged carrier writes reach the client on a refusal too (they were +// dropped), and on a handler error. +func TestWrapUnaryFlushesStagedHeadersOnRefusalAndHandlerError(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(security.NewManager(tokenAuthenticator{}), writingExtractor{}) + interceptor := connectrpcsec.NewAuthenticationInterceptor(engine) + + _, err := interceptor.WrapUnary((&recordingUnary{}).fn)(context.Background(), unaryReq("wrong")) + assert.Equal(t, "applied", connectError(t, err).Meta().Get("X-Authn-Trace")) + + failing := func(context.Context, connect.AnyRequest) (connect.AnyResponse, error) { + return nil, connect.NewError(connect.CodeAborted, errors.New("handler failed")) + } + + _, err = interceptor.WrapUnary(failing)(context.Background(), unaryReq("letmein")) + assert.Equal(t, "applied", connectError(t, err).Meta().Get("X-Authn-Trace")) + + conn := newStreamConn(bearerHeader("wrong")) + err = interceptor.WrapStreamingHandler((&recordingStream{}).fn)(context.Background(), conn) + require.Error(t, err) + assert.Equal(t, "applied", conn.ResponseHeader().Get("X-Authn-Trace")) +} + +// Audit CORE-15: printing a Carrier printed the incoming headers, the +// bearer token included. +func TestCarrierRedactsItself(t *testing.T) { + t.Parallel() + + const token = "eyJhbGciOi.secret.token" + + c := connectrpcsec.NewCarrier(http.Header{"Authorization": {"Bearer " + token}}) + c.Set("X-Staged", "staged-value") + + var printed []string + + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%d", "%x"} { + printed = append(printed, fmt.Sprintf(verb, c), fmt.Sprintf(verb, *c)) + } + + for _, handler := range []func(*bytes.Buffer) slog.Handler{ + func(b *bytes.Buffer) slog.Handler { return slog.NewTextHandler(b, nil) }, + func(b *bytes.Buffer) slog.Handler { return slog.NewJSONHandler(b, nil) }, + } { + var buf bytes.Buffer + + slog.New(handler(&buf)).Info("rpc", "carrier", c, "value", *c) + printed = append(printed, buf.String()) + } + + for _, out := range printed { + assert.NotContains(t, out, token) + assert.NotContains(t, out, "staged-value") + } + + assert.Equal(t, "connectrpcsec.Carrier{Incoming: 1 keys, Staged: 1 keys}", c.String()) +} + +// The audit's surviving mutant "invalid credentials tolerated under +// anonymous fallback". +func TestInterceptorRefusesInvalidCredentialsUnderAnonymousFallback(t *testing.T) { + t.Parallel() + + interceptor := connectrpcsec.NewAuthenticationInterceptor(newEngine(), connectrpcsec.WithAnonymousFallback(true)) + + _, err := interceptor.WrapUnary((&recordingUnary{}).fn)(context.Background(), unaryReq("wrong")) + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) + + err = interceptor.WrapStreamingHandler((&recordingStream{}).fn)(context.Background(), newStreamConn(bearerHeader("wrong"))) + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) +} + +// Audit CORE-I3. +func TestInterceptorFailsClosedOnABrokenEngine(t *testing.T) { + t.Parallel() + + interceptor := connectrpcsec.NewAuthenticationInterceptor(brokenEngine{}, connectrpcsec.WithAnonymousFallback(true)) + + var err error + + require.NotPanics(t, func() { + _, err = interceptor.WrapUnary((&recordingUnary{}).fn)(context.Background(), unaryReq("letmein")) + }) + assert.Equal(t, connect.CodeInternal, connect.CodeOf(err)) + + require.NotPanics(t, func() { + err = interceptor.WrapStreamingHandler((&recordingStream{}).fn)(context.Background(), newStreamConn(nil)) + }) + assert.Equal(t, connect.CodeInternal, connect.CodeOf(err)) +} + +type fixedMapper struct{ code connect.Code } + +func (m fixedMapper) Map(context.Context, error) error { + return connect.NewError(m.code, errors.New("custom")) +} + +func TestAuthorizationUsesTheAuthenticationInterceptorsMapper(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) + attrs := []security.Attribute{security.Role("ADMIN")} + authn := connectrpcsec.NewAuthenticationInterceptor(newEngine("ROLE_USER"), + connectrpcsec.WithErrorMapper(fixedMapper{code: connect.CodeAborted})) + + _, err := chainUnary(authn, connectrpcsec.NewAuthorizationInterceptor(adm, attrs), (&recordingUnary{}).fn)( + context.Background(), unaryReq("letmein")) + assert.Equal(t, connect.CodeAborted, connect.CodeOf(err)) + + own := connectrpcsec.NewAuthorizationInterceptor(adm, attrs, connectrpcsec.WithErrorMapper(fixedMapper{code: connect.CodeDataLoss})) + _, err = chainUnary(authn, own, (&recordingUnary{}).fn)(context.Background(), unaryReq("letmein")) + assert.Equal(t, connect.CodeDataLoss, connect.CodeOf(err)) +} + +func TestAuthorizationRefusesANilDecisionManager(t *testing.T) { + t.Parallel() + + assert.PanicsWithValue(t, "connectrpcsec: NewAuthorizationInterceptor: nil AccessDecisionManager", + func() { connectrpcsec.NewAuthorizationInterceptor(nil, nil) }) +} + +func TestAuthorizationKeepsANonAccessErrorOfACustomManager(t *testing.T) { + t.Parallel() + + for want, err := range map[connect.Code]error{ + connect.CodeUnavailable: fmt.Errorf("policy store: %w", security.ErrUnavailable), + connect.CodeInternal: errors.New("policy bug"), + } { + authz := connectrpcsec.NewAuthorizationInterceptor(scriptedADM{err: err}, nil) + + _, got := authz.WrapUnary((&recordingUnary{}).fn)(context.Background(), unaryReq("")) + assert.Equal(t, want, connect.CodeOf(got)) + } +} + +type scriptedADM struct{ err error } + +func (s scriptedADM) Decide(context.Context, security.Authentication, []security.Attribute) error { + return s.err +} diff --git a/connectrpc/interceptor.go b/connectrpc/interceptor.go index ccbbe1cc..75d03f57 100644 --- a/connectrpc/interceptor.go +++ b/connectrpc/interceptor.go @@ -18,8 +18,13 @@ import ( const tracerName = "github.com/hyperscale-stack/security/connectrpc" +// errEngineContract reports a custom [security.Engine] that returned no +// context or no authentication without an error: an internal error. +var errEngineContract = errors.New("connectrpcsec: the engine returned no context or authentication") + // flushHeader copies the staged carrier writes into dst, a live response -// header. In the common case the engine writes nothing and this is a no-op. +// header or an error's metadata. In the common case the engine writes +// nothing and this is a no-op. func flushHeader(dst, staged http.Header) { for key, values := range staged { for _, value := range values { @@ -28,6 +33,17 @@ func flushHeader(dst, staged http.Header) { } } +// withStaged merges the staged carrier writes into the metadata of a +// Connect error — the response headers of a refused unary call. +func withStaged(err error, carrier *Carrier) error { + var ce *connect.Error + if errors.As(err, &ce) { + flushHeader(ce.Meta(), carrier.ResponseHeader()) + } + + return err +} + // authenticate runs the engine against the RPC request header and returns the // enriched context together with the Carrier (so the caller can flush staged // response headers). It is shared by the unary and streaming handlers. @@ -46,17 +62,18 @@ func authenticate( carrier := NewCarrier(header) newCtx, auth, err := engine.Process(ctx, carrier) - if err != nil { - // "no extractor configured" is tolerated only when the caller - // opted into anonymous fallback; every other error is fatal. - tolerated := cfg.anonymousFallback && errors.Is(err, security.ErrNoExtractor) - if !tolerated { - return ctx, carrier, fmt.Errorf("connectrpcsec: authenticate: %w", err) - } - } - if !auth.IsAuthenticated() && !cfg.anonymousFallback { - return ctx, carrier, security.ErrInvalidCredentials + // "no extractor configured" is tolerated only when the caller opted into + // anonymous fallback; every other error is fatal. + tolerated := cfg.anonymousFallback && errors.Is(err, security.ErrNoExtractor) + + switch { + case err != nil && !tolerated: + return ctx, carrier, fmt.Errorf("connectrpcsec: authenticate: %w", err) + case newCtx == nil || auth == nil: + return ctx, carrier, errEngineContract + case !auth.IsAuthenticated() && !cfg.anonymousFallback: + return ctx, carrier, security.ErrAuthenticationRequired } span.SetAttributes(attribute.Bool("security.authenticated", auth.IsAuthenticated())) @@ -68,7 +85,9 @@ func authenticate( // every inbound RPC against a [security.Engine]. On success the handler runs // with the request context enriched via [security.WithAuthentication]; on // failure the configured [ErrorMapper] turns the security error into a -// Connect error and the handler is not invoked. +// Connect error — with its WWW-Authenticate challenge — and the handler is +// not invoked. The headers the extractors stage on the [Carrier] reach the +// client on every path: the response, a refusal or a handler error. // // It opens a "connectrpcsec.Authenticate" span but deliberately does NOT open // an "rpc" span — that belongs to otelconnect, which users compose alongside @@ -76,14 +95,18 @@ func authenticate( type AuthenticationInterceptor struct { engine security.Engine cfg *config + mapper ErrorMapper } // NewAuthenticationInterceptor builds a [connect.Interceptor] that // authenticates every inbound unary and streaming RPC against engine. Install // it with connect.WithInterceptors(...). Client-side calls are passed through -// untouched. +// untouched. It panics on an invalid challenge configuration (see +// [DefaultErrorMapper]). func NewAuthenticationInterceptor(engine security.Engine, opts ...Option) *AuthenticationInterceptor { - return &AuthenticationInterceptor{engine: engine, cfg: buildConfig(opts...)} + cfg := buildConfig(opts...) + + return &AuthenticationInterceptor{engine: engine, cfg: cfg, mapper: cfg.ownMapper()} } // Compile-time check. @@ -99,12 +122,14 @@ func (i *AuthenticationInterceptor) WrapUnary(next connect.UnaryFunc) connect.Un newCtx, carrier, err := authenticate(ctx, i.engine, i.cfg, req.Spec().Procedure, req.Header()) if err != nil { - return nil, i.cfg.errorMapper.Map(ctx, err) + return nil, withStaged(i.mapper.Map(withBearerToken(ctx, req.Header()), err), carrier) } - resp, err := next(newCtx, req) + resp, err := next(withErrorMapper(newCtx, i.mapper), req) if resp != nil { flushHeader(resp.Header(), carrier.ResponseHeader()) + } else { + err = withStaged(err, carrier) } return resp, err //nolint:wrapcheck // the handler / connect error is the terminal wire value @@ -119,13 +144,14 @@ func (i *AuthenticationInterceptor) WrapStreamingHandler( ) connect.StreamingHandlerFunc { return func(ctx context.Context, conn connect.StreamingHandlerConn) error { newCtx, carrier, err := authenticate(ctx, i.engine, i.cfg, conn.Spec().Procedure, conn.RequestHeader()) - if err != nil { - return i.cfg.errorMapper.Map(ctx, err) - } flushHeader(conn.ResponseHeader(), carrier.ResponseHeader()) - return next(newCtx, conn) //nolint:wrapcheck // the handler error is the terminal wire value + if err != nil { + return i.mapper.Map(withBearerToken(ctx, conn.RequestHeader()), err) + } + + return next(withErrorMapper(newCtx, i.mapper), conn) //nolint:wrapcheck // the handler error is the terminal wire value } } diff --git a/connectrpc/interceptor_test.go b/connectrpc/interceptor_test.go index 8f7e83b0..9f21acb8 100644 --- a/connectrpc/interceptor_test.go +++ b/connectrpc/interceptor_test.go @@ -221,7 +221,7 @@ func TestWrapStreamingClientIsPassThrough(t *testing.T) { func TestInterceptorCustomErrorMapper(t *testing.T) { t.Parallel() - mapper := &recordingMapper{ErrorMapper: connectrpcsec.DefaultErrorMapper()} + mapper := &recordingMapper{ErrorMapper: connectrpcsec.DefaultErrorMapper("", "")} spy := &recordingUnary{} wrapped := connectrpcsec.NewAuthenticationInterceptor( newEngine(), diff --git a/connectrpc/options.go b/connectrpc/options.go index aaac8b43..cb2cd248 100644 --- a/connectrpc/options.go +++ b/connectrpc/options.go @@ -4,10 +4,17 @@ package connectrpcsec +import "context" + // config is the consolidated interceptor configuration, built from the // applied [Option] values. type config struct { + // errorMapper is the mapper given with WithErrorMapper, or the default + // one built from the challenge options; nil without either. errorMapper ErrorMapper + challengeScheme string + realm string + challengeSet bool anonymousFallback bool } @@ -15,7 +22,10 @@ type config struct { type Option func(*config) // WithErrorMapper overrides the [ErrorMapper] used to translate security -// errors into Connect errors. Defaults to [DefaultErrorMapper]. +// errors into Connect errors (it takes precedence over [WithChallengeScheme] +// and [WithRealm]). Without it the authentication interceptor uses +// [DefaultErrorMapper], and the authorization interceptor the mapper of the +// authentication interceptor that ran before it. func WithErrorMapper(m ErrorMapper) Option { return func(c *config) { if m != nil { @@ -24,21 +34,79 @@ func WithErrorMapper(m ErrorMapper) Option { } } +// WithChallengeScheme sets the scheme of the WWW-Authenticate challenge the +// default mapper sends on Unauthenticated and PermissionDenied errors — +// "Bearer" (default) or "Basic". +func WithChallengeScheme(scheme string) Option { + return func(c *config) { + c.challengeScheme = scheme + c.challengeSet = true + } +} + +// WithRealm sets the realm of the default mapper's challenges. Default: +// [DefaultRealm]. Visible ASCII, spaces and tabs only — anything else makes +// the interceptor constructor panic. +func WithRealm(realm string) Option { + return func(c *config) { + c.realm = realm + c.challengeSet = true + } +} + // WithAnonymousFallback controls what happens when no extractor finds a // credential. With true, the RPC proceeds carrying the anonymous // [security.Authentication] and downstream authorisation interceptors are // responsible for rejecting it. Default: false (reject with -// connect.CodeUnauthenticated immediately). +// connect.CodeUnauthenticated immediately). Invalid credentials are refused +// whatever the fallback. func WithAnonymousFallback(allow bool) Option { return func(c *config) { c.anonymousFallback = allow } } -// buildConfig applies opts onto the default config. +// buildConfig applies opts onto the default config. A challenge option +// without an explicit mapper builds the default mapper here, so an invalid +// configuration fails at construction. func buildConfig(opts ...Option) *config { - cfg := &config{errorMapper: DefaultErrorMapper()} + cfg := &config{} for _, o := range opts { o(cfg) } + if cfg.errorMapper == nil && cfg.challengeSet { + cfg.errorMapper = DefaultErrorMapper(cfg.challengeScheme, cfg.realm) + } + return cfg } + +// ownMapper returns the configured mapper, or the default one. +func (c *config) ownMapper() ErrorMapper { + if c.errorMapper != nil { + return c.errorMapper + } + + return DefaultErrorMapper("", "") +} + +// mapper returns the configured mapper, else the one the authentication +// interceptor published in ctx, else the default one. +func (c *config) mapper(ctx context.Context) ErrorMapper { + if c.errorMapper != nil { + return c.errorMapper + } + + if m, ok := ctx.Value(errorMapperKey{}).(ErrorMapper); ok && m != nil { + return m + } + + return DefaultErrorMapper("", "") +} + +// errorMapperKey is the context key under which the authentication +// interceptor publishes its error mapper for the authorization one. +type errorMapperKey struct{} + +func withErrorMapper(ctx context.Context, m ErrorMapper) context.Context { + return context.WithValue(ctx, errorMapperKey{}, m) +} diff --git a/connectrpc/testing_helpers_test.go b/connectrpc/testing_helpers_test.go index 55b7262f..92b5e88a 100644 --- a/connectrpc/testing_helpers_test.go +++ b/connectrpc/testing_helpers_test.go @@ -57,7 +57,11 @@ func (a fakeAuth) Name() string { return a.pr.Subject() } type tokenExtractor struct{} func (tokenExtractor) Extract(_ context.Context, c security.Carrier) (security.Authentication, error) { - v := c.Get("authorization") + v, err := security.SingleValue(c, "authorization") + if err != nil { + return nil, err //nolint:wrapcheck // test double + } + if v == "" { return nil, nil } diff --git a/docs/architecture.md b/docs/architecture.md index 28bb43d3..a0d2faa2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -186,7 +186,8 @@ and a `Carrier`, then map security errors to transport responses. - **`connectrpcsec`** — `NewAuthenticationInterceptor` authenticates every RPC; `NewAuthorizationInterceptor` enforces an ADM. Both return a `connect.Interceptor` covering unary and streaming. `ErrorMapper` turns - sentinels into `connect.Code`. + `security.ErrorClass` into `connect.Code` with the `WWW-Authenticate` + challenge in the error's metadata. ## OAuth2 diff --git a/docs/security-considerations.md b/docs/security-considerations.md index d04b74a3..3353b765 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1144,7 +1144,7 @@ Error mappers return terse, code-first responses, classified once by authenticator is never masked by another one's lack of support, and an outage never reads as a verdict on the credentials: -| Class | HTTP | `WWW-Authenticate` (scheme S, realm R) | gRPC | +| Class | HTTP | `WWW-Authenticate` (scheme S, realm R) | gRPC / ConnectRPC | | --- | --- | --- | --- | | `invalid_request` (e.g. repeated `Authorization`) | 400 | Bearer + bearer token sent: `error="invalid_request"` | `InvalidArgument` | | `unavailable` (`ErrUnavailable`) | 503 | — | `Unavailable` | @@ -1153,6 +1153,11 @@ outage never reads as a verdict on the credentials: | `insufficient_scope`, `access_denied` | 403 | `S realm=R`; Bearer + bearer token sent: `error="insufficient_scope"` | `PermissionDenied` | | `no_extractor`, `_OTHER` (no sentinel: a broken contract) | 500 | — | `Internal` | +A ConnectRPC error travels over HTTP with the status of the HTTP column, and +the `connectrpcsec` default mapper puts the same `WWW-Authenticate` +challenge in its metadata — sent as response headers on unary calls — so +every Connect 401 carries one. + Every 401 carries a challenge with a realm (`api` by default; RFC 9110 §11.6.1, RFC 7617 §2, RFC 6750 §3), a Basic one also `charset="UTF-8"` (RFC 7617 §2.1), and the realm is sent as an RFC 9110 quoted-string — a diff --git a/examples/connectrpc-bearer/challenge_test.go b/examples/connectrpc-bearer/challenge_test.go new file mode 100644 index 00000000..670e9a18 --- /dev/null +++ b/examples/connectrpc-bearer/challenge_test.go @@ -0,0 +1,89 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-10: a Connect unary 401 carried no WWW-Authenticate field +// (RFC 9110 §11.6.1). Audit CORE-02 / decision 6: credentials are read from +// header fields only. +func TestConnectRPCBearerChallenges(t *testing.T) { + t.Parallel() + + handler, mint, err := newServer() + require.NoError(t, err) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + good, err := mint(scope) + require.NoError(t, err) + + wrongScope, err := mint("other:read") + require.NoError(t, err) + + call := func(t *testing.T, query string, authorization ...string) (int, []string) { + t.Helper() + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, + srv.URL+"/grpc.health.v1.Health/Check"+query, strings.NewReader("{}")) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + for _, v := range authorization { + req.Header.Add("Authorization", v) + } + + resp, err := srv.Client().Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + return resp.StatusCode, resp.Header.Values("WWW-Authenticate") + } + + const ( + bare = `Bearer realm="api"` + invalidToken = `Bearer realm="api", error="invalid_token", error_description="The access token is invalid or has expired."` + insufficient = `Bearer realm="api", error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token."` + ) + + cases := []struct { + name string + query string + authorization []string + status int + challenge []string + }{ + {"no token", "", nil, http.StatusUnauthorized, []string{bare}}, + {"garbage token", "", []string{"Bearer not-a-jwt"}, http.StatusUnauthorized, []string{invalidToken}}, + {"token in the query string", "?Authorization=" + url.QueryEscape("Bearer "+good), nil, http.StatusUnauthorized, []string{bare}}, + {"repeated authorization", "", []string{"Bearer " + good, "Bearer not-a-jwt"}, http.StatusBadRequest, + []string{`Bearer realm="api", error="invalid_request", error_description="The request is malformed."`}}, + {"valid token without the scope", "", []string{"Bearer " + wrongScope}, http.StatusForbidden, []string{insufficient}}, + {"valid token", "", []string{"Bearer " + good}, http.StatusOK, nil}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + status, challenge := call(t, c.query, c.authorization...) + + assert.Equal(t, c.status, status) + assert.Equal(t, c.challenge, challenge) + }) + } +} From 9979134a7cd9e457e75da0f2cce11e2ffebf8eb1 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 00:49:36 +0200 Subject: [PATCH 10/29] fix(examples): authenticate and authorize streaming RPCs in grpc-bearer The grpc-bearer example chained only unary interceptors, and the health service it registers also serves Watch, a server-streaming RPC: any caller, without a token, received the health status stream. Copying the documented wiring left every streaming RPC of an application unauthenticated. The example now builds its server from grpcsec.ServerOptions and grpcsec.AuthorizeServerOptions, which install the unary and the stream chains together, and a test proves Watch is refused without a token and without the scope. Audit: CORE-05. --- CHANGELOG.md | 4 ++ examples/grpc-bearer/main.go | 21 +++--- examples/grpc-bearer/streaming_test.go | 90 ++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 examples/grpc-bearer/streaming_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 16bc76d6..5227201c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1290,3 +1290,7 @@ legacy packages (`authentication/`, `authorization/`, the in-tree in `Authorization` — with `%v`, and the Connect error messages told the client why authentication failed; the carrier now prints and logs key counts only, and the messages are one fixed string per code. +- The `grpc-bearer` example and the `grpcsec` godoc example installed only + unary interceptors: the streaming `Health.Watch` RPC was served to callers + without any token. Both now install the unary and stream chains through + `grpcsec.ServerOptions` / `grpcsec.AuthorizeServerOptions`. diff --git a/examples/grpc-bearer/main.go b/examples/grpc-bearer/main.go index d738be5a..f8ee9656 100644 --- a/examples/grpc-bearer/main.go +++ b/examples/grpc-bearer/main.go @@ -4,9 +4,10 @@ // Command grpc-bearer is a runnable gRPC Bearer-token demo. // -// It exposes the standard gRPC health service behind two interceptors: one -// authenticates every RPC against a JWT, the other authorizes it against an -// OAuth2 scope. The process also mints a demo token at start-up. +// It exposes the standard gRPC health service behind two interceptor chains +// — unary and streaming: one authenticates every RPC against a JWT, the +// other authorizes it against an OAuth2 scope. The process also mints a +// demo token at start-up. // // Run: // @@ -29,6 +30,7 @@ import ( "fmt" "log" "net" + "slices" "time" "github.com/hyperscale-stack/security" @@ -84,12 +86,13 @@ func newServer() (*grpc.Server, minter, error) { adm := security.NewAffirmativeDecisionManager(voter.HasScope(scope)) - srv := grpc.NewServer( - grpc.ChainUnaryInterceptor( - grpcsec.UnaryServerInterceptor(engine), - grpcsec.UnaryAuthorize(adm, []security.Attribute{security.Scope(scope)}), - ), - ) + // ServerOptions and AuthorizeServerOptions install the unary AND the + // stream interceptors: chaining only unary ones would leave the + // streaming Health.Watch RPC unauthenticated. + srv := grpc.NewServer(slices.Concat( + grpcsec.ServerOptions(engine), + grpcsec.AuthorizeServerOptions(adm, []security.Attribute{security.Scope(scope)}), + )...) healthpb.RegisterHealthServer(srv, health.NewServer()) mint := func(grant string) (string, error) { diff --git a/examples/grpc-bearer/streaming_test.go b/examples/grpc-bearer/streaming_test.go new file mode 100644 index 00000000..d9197a27 --- /dev/null +++ b/examples/grpc-bearer/streaming_test.go @@ -0,0 +1,90 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +// Audit CORE-05 (probe TestZZAuditStreamingRPCUnauthenticated): the example +// chained only unary interceptors, so the server-streaming Health.Watch RPC +// was served to callers without any token. +func TestGRPCBearerAuthenticatesAndAuthorizesStreamingRPCs(t *testing.T) { + t.Parallel() + + srv, mint, err := newServer() + require.NoError(t, err) + + lis := bufconn.Listen(1 << 20) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + client := healthpb.NewHealthClient(conn) + + good, err := mint(scope) + require.NoError(t, err) + + wrongScope, err := mint("other:read") + require.NoError(t, err) + + watch := func(t *testing.T, token string) (codes.Code, *healthpb.HealthCheckResponse) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + + if token != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token) + } + + stream, err := client.Watch(ctx, &healthpb.HealthCheckRequest{}) + require.NoError(t, err) + + msg, err := stream.Recv() + + return status.Code(err), msg + } + + t.Run("without token", func(t *testing.T) { + t.Parallel() + + code, _ := watch(t, "") + assert.Equal(t, codes.Unauthenticated, code) + }) + + t.Run("without the scope", func(t *testing.T) { + t.Parallel() + + code, _ := watch(t, wrongScope) + assert.Equal(t, codes.PermissionDenied, code) + }) + + t.Run("with the scope", func(t *testing.T) { + t.Parallel() + + code, msg := watch(t, good) + require.Equal(t, codes.OK, code) + assert.Equal(t, healthpb.HealthCheckResponse_SERVING, msg.GetStatus()) + }) +} From 76170abf4786155ad3233753ebf0bc63fd6c9aff Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 00:50:32 +0200 Subject: [PATCH 11/29] docs: document the extractor order and fix transport documentation drift The Engine tries its extractors in order and the first one finding a credential wins: security-considerations now explains the CSRF exposure of an engine that accepts a cookie session before header-based schemes, and recommends one engine per audience. LIMITATIONS lists the ConnectRPC adapter it omitted, and the operator checklist gains the voter, challenge, gRPC stream and ErrUnavailable items of this change. Audit: CORE-I10, CORE-I11. --- CHANGELOG.md | 5 +++++ LIMITATIONS.md | 12 +++++++----- docs/security-considerations.md | 25 +++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5227201c..2d26cb36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -821,6 +821,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree challenge. Headers staged on the `connectrpcsec.Carrier` reach the client on a refused call and on a handler error too, and a custom `Engine` returning no authentication no longer panics the interceptors. +- Documentation drift: `AttrPrincipalSubject` no longer references a + non-existent `SetSubjectAttributeMode`, the HTTP middleware no longer + promises a route override it could not offer, and `LIMITATIONS.md` lists + the ConnectRPC adapter. `docs/security-considerations.md` documents the + extractor order (first extractor wins) and its CSRF implication. - The SAML Redis store answered `ErrTransactionExists` only when a second save differed from the stored transaction: an identical one succeeded. It diff --git a/LIMITATIONS.md b/LIMITATIONS.md index b1791284..7677192c 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -1,8 +1,8 @@ # Known limitations -The library covers HTTP and gRPC transports, HTTP Basic / Bearer schemes, -password hashing, JWT, OAuth2 (issuer + resource server), OpenID Connect -(provider and relying party), a SAML 2.0 Service Provider, production +The library covers HTTP, gRPC and ConnectRPC transports, HTTP Basic / Bearer +schemes, password hashing, JWT, OAuth2 (issuer + resource server), OpenID +Connect (provider and relying party), a SAML 2.0 Service Provider, production storage backends, and stateless cookie sessions. This document tracks what is **not** yet covered. Remaining items are tracked as GitHub issues rather than future refactor phases. @@ -342,8 +342,10 @@ PKCE. Not supported, and refused where a provider could select them: ## Transports -- Only `net/http` and gRPC adapters are shipped. Other transports can be - added downstream by implementing `security.Carrier`. +- Only the `net/http` (`httpsec`), gRPC (`grpcsec`) and ConnectRPC + (`connectrpcsec`) adapters are shipped. Other transports can be added + downstream by implementing `security.Carrier` (header fields only — never + the URL or cookies) and mapping errors with `security.ErrorClass`. ## Sessions diff --git a/docs/security-considerations.md b/docs/security-considerations.md index 3353b765..f4178a35 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1104,6 +1104,19 @@ the `Authorization` field alone, never from the URL query string (RFC 6750 from a cookie named like the field (an ambient credential a cross-site request would carry). +## Extractor order + +The `Engine` tries its extractors in order and the first one that finds a +credential wins (as in Spring Security's filter chain): a deliberate design, +not a fallback between schemes. Put the header-based extractors (bearer, +basic) before the session extractor: a browser attaches cookies to +cross-site requests by itself — `SameSite=Lax` still sends them on +top-level GET navigations — but never an `Authorization` header, so an +engine that accepts a cookie session authenticates requests an attacker's +page can trigger. Protect every state-changing endpoint reached with a +cookie session against CSRF, and prefer one engine per audience (browser +pages, APIs) so that an API never authenticates an ambient cookie. + ## Authorization Authorization follows Spring Security's voter model, and fails closed: @@ -1308,6 +1321,18 @@ span catalog. Referrer-Policy header changed first); otherwise keep `TransactionTTL` short and never log full IdP redirect URLs. - [ ] Install an OpenTelemetry `TracerProvider` to collect the spans. +- [ ] Give every route the attributes its voters check, and a voter for each + attribute (an unsupported one denies); require a logged-in caller + with `security.IsAuthenticated`, and store roles as `ROLE_` + and scopes as one `scope:` authority each. +- [ ] Set the challenge scheme of each HTTP / ConnectRPC adapter to the + scheme its extractors accept, with a printable-ASCII realm. +- [ ] On gRPC, install the authentication and authorization interceptors + for both RPC kinds (`grpcsec.ServerOptions`, + `grpcsec.AuthorizeServerOptions`). +- [ ] In your authenticators, wrap `security.ErrUnavailable` for a + dependency that is temporarily down, a credential sentinel for a + refusal — never a plain error. - [ ] Keep the zero `outbound.Policy` in production; exempt only the exact subnets of on-premises identity providers in `AllowedPrefixes`, and restrict hosts and ports in `CheckURL` when tenants configure URLs. From 1d6b60475d659d8cc8206f7dcea4a4a4d88cf019 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 00:59:24 +0200 Subject: [PATCH 12/29] refactor: remove unreachable branches and pin the default-deny baseline - voter: a composite holds its combining function instead of a mode switch with an unreachable fallback. - httpsec, connectrpcsec: isToken checks token = 1*tchar after the loop, pinned by an internal grammar test (the constructors map an empty scheme to "Bearer" first). - grpcsec, connectrpcsec: drop the carriers' GoString; Format already serves %#v. - Tests pin the default-deny baseline of every decision strategy (no attribute, all abstentions, no voter), a consensus majority of denies, the state attribute strings, grpcsec.Carrier.Get of an absent key, and httpsec.Middleware with an engine without extractors (500, or the anonymous fallback). Audit: CORE-01, CORE-10, CORE-13, CORE-15. --- access_decision_manager_supported_test.go | 45 ++++++++++++++++++++ connectrpc/carrier.go | 3 -- connectrpc/challenge.go | 6 +-- connectrpc/challenge_internal_test.go | 32 ++++++++++++++ grpc/carrier.go | 3 -- grpc/carrier_test.go | 1 + http/challenge.go | 6 +-- http/challenge_internal_test.go | 32 ++++++++++++++ http/mapping_test.go | 15 +++++++ voter/composite.go | 52 +++++++++-------------- 10 files changed, 148 insertions(+), 47 deletions(-) create mode 100644 connectrpc/challenge_internal_test.go create mode 100644 http/challenge_internal_test.go diff --git a/access_decision_manager_supported_test.go b/access_decision_manager_supported_test.go index b482dbb1..28659969 100644 --- a/access_decision_manager_supported_test.go +++ b/access_decision_manager_supported_test.go @@ -83,3 +83,48 @@ func TestDecidePassesEachVoterOnlyTheAttributesItSupports(t *testing.T) { assert.Equal(t, []string{"scope:read", "scope:write"}, scopes.seen) assert.Equal(t, []string{"role:ADMIN"}, roles.seen) } + +// The audit's positive baseline, pinned: every strategy denies when there is +// no attribute, when every voter abstains, and when there is no voter. +func TestEveryStrategyDeniesByDefault(t *testing.T) { + t.Parallel() + + alice := newFakeAuth("alice").withAuthenticated() + abstain := &scriptedVoter{prefix: "scope:", vote: security.DecisionAbstain} + + for name, adm := range map[string]security.AccessDecisionManager{ + "affirmative": security.NewAffirmativeDecisionManager(abstain), + "consensus": security.NewConsensusDecisionManager([]security.Voter{abstain}), + "unanimous": security.NewUnanimousDecisionManager([]security.Voter{abstain}), + "affirmative no voters": security.NewAffirmativeDecisionManager(), + } { + for attrsName, attrs := range map[string][]security.Attribute{ + "nil attrs": nil, + "empty attrs": {}, + "abstained": {stringAttr("scope:read")}, + } { + assert.ErrorIs(t, adm.Decide(context.Background(), alice, attrs), security.ErrAccessDenied, "%s, %s", name, attrsName) + } + } +} + +func TestConsensusDeniesOnAMajorityOfDenies(t *testing.T) { + t.Parallel() + + adm := security.NewConsensusDecisionManager([]security.Voter{ + &scriptedVoter{prefix: "scope:", vote: security.DecisionDeny}, + &scriptedVoter{prefix: "scope:", vote: security.DecisionDeny}, + &scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}, + }, security.WithTieBreak(security.DecisionGrant)) + + err := adm.Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), []security.Attribute{stringAttr("scope:read")}) + + assert.ErrorIs(t, err, security.ErrAccessDenied) +} + +func TestStateAttributeStrings(t *testing.T) { + t.Parallel() + + assert.Equal(t, "state:authenticated", security.IsAuthenticated.String()) + assert.Equal(t, "state:anonymous", security.IsAnonymous.String()) +} diff --git a/connectrpc/carrier.go b/connectrpc/carrier.go index dbefdda6..ff63c5af 100644 --- a/connectrpc/carrier.go +++ b/connectrpc/carrier.go @@ -83,9 +83,6 @@ func (c Carrier) String() string { return fmt.Sprintf("connectrpcsec.Carrier{Incoming: %d keys, Staged: %d keys}", len(c.in), len(c.out)) } -// GoString implements fmt.GoStringer like String. -func (c Carrier) GoString() string { return c.String() } - // Format implements fmt.Formatter like String, for every verb: without it, // fmt prints the header maps. fmt handles %p of a non-pointer value before // any method: no type can redact that one. diff --git a/connectrpc/challenge.go b/connectrpc/challenge.go index 13fa0dcf..511148fb 100644 --- a/connectrpc/challenge.go +++ b/connectrpc/challenge.go @@ -17,10 +17,6 @@ import ( // isToken reports whether s is an RFC 9110 §5.6.2 token — the syntax of an // auth-scheme. func isToken(s string) bool { - if s == "" { - return false - } - for i := range len(s) { c := s[i] @@ -32,7 +28,7 @@ func isToken(s string) bool { } } - return true + return s != "" // token = 1*tchar } // quoteString renders s as an RFC 9110 §5.6.4 quoted-string: DQUOTE and diff --git a/connectrpc/challenge_internal_test.go b/connectrpc/challenge_internal_test.go new file mode 100644 index 00000000..8f7ad017 --- /dev/null +++ b/connectrpc/challenge_internal_test.go @@ -0,0 +1,32 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// The public constructors map an empty scheme to "Bearer" before checking +// it: the grammar helper itself must still refuse an empty token +// (RFC 9110 §5.6.2, token = 1*tchar). +func TestIsToken(t *testing.T) { + t.Parallel() + + for s, want := range map[string]bool{ + "": false, + "Bearer": true, + "DPoP": true, + "!#$%&'*+-.^_`|~09azAZ": true, + "Bea rer": false, + "Bearer\r\n": false, + "a/b": false, + "a=b": false, + "caf\u00e9": false, + } { + assert.Equal(t, want, isToken(s), "%q", s) + } +} diff --git a/grpc/carrier.go b/grpc/carrier.go index 1361e904..37bcb352 100644 --- a/grpc/carrier.go +++ b/grpc/carrier.go @@ -84,9 +84,6 @@ func (c Carrier) String() string { return fmt.Sprintf("grpcsec.Carrier{Incoming: %d keys, Staged: %d keys}", len(c.in), len(c.out)) } -// GoString implements fmt.GoStringer like String. -func (c Carrier) GoString() string { return c.String() } - // Format implements fmt.Formatter like String, for every verb: without it, // fmt prints the metadata maps. fmt handles %p of a non-pointer value before // any method: no type can redact that one. diff --git a/grpc/carrier_test.go b/grpc/carrier_test.go index 91416365..018850d9 100644 --- a/grpc/carrier_test.go +++ b/grpc/carrier_test.go @@ -26,6 +26,7 @@ func TestCarrierReads(t *testing.T) { assert.Equal(t, "Bearer one", c.Get("Authorization")) assert.Equal(t, []string{"Bearer one", "Bearer two"}, c.Values("Authorization")) assert.Empty(t, c.Values("x-absent")) + assert.Empty(t, c.Get("x-absent")) } func TestCarrierWritesResponseMetadata(t *testing.T) { diff --git a/http/challenge.go b/http/challenge.go index df7044e0..5452e68a 100644 --- a/http/challenge.go +++ b/http/challenge.go @@ -15,10 +15,6 @@ import ( // isToken reports whether s is an RFC 9110 §5.6.2 token — the syntax of an // auth-scheme. func isToken(s string) bool { - if s == "" { - return false - } - for i := range len(s) { c := s[i] @@ -30,7 +26,7 @@ func isToken(s string) bool { } } - return true + return s != "" // token = 1*tchar } // quoteString renders s as an RFC 9110 §5.6.4 quoted-string: DQUOTE and diff --git a/http/challenge_internal_test.go b/http/challenge_internal_test.go new file mode 100644 index 00000000..05b83cd8 --- /dev/null +++ b/http/challenge_internal_test.go @@ -0,0 +1,32 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package httpsec + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// The public constructors map an empty scheme to "Bearer" before checking +// it: the grammar helper itself must still refuse an empty token +// (RFC 9110 §5.6.2, token = 1*tchar). +func TestIsToken(t *testing.T) { + t.Parallel() + + for s, want := range map[string]bool{ + "": false, + "Bearer": true, + "DPoP": true, + "!#$%&'*+-.^_`|~09azAZ": true, + "Bea rer": false, + "Bearer\r\n": false, + "a/b": false, + "a=b": false, + "caf\u00e9": false, + } { + assert.Equal(t, want, isToken(s), "%q", s) + } +} diff --git a/http/mapping_test.go b/http/mapping_test.go index 560bcb48..80c1432d 100644 --- a/http/mapping_test.go +++ b/http/mapping_test.go @@ -281,3 +281,18 @@ func TestMiddlewareWithoutCredentialsSendsNoErrorCode(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, rec.Code) assert.Equal(t, []string{bearerAPI}, rec.Header().Values("WWW-Authenticate")) } + +// An engine without any extractor is a misconfiguration (500), tolerated +// only under the anonymous fallback. +func TestMiddlewareEngineWithoutExtractor(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(security.NewManager()) + + rec := serve(httpsec.Middleware(engine)(next204()), httptest.NewRequest(http.MethodGet, "/", nil)) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Empty(t, rec.Header().Values("WWW-Authenticate")) + + rec = serve(httpsec.Middleware(engine, httpsec.WithAnonymousFallback(true))(next204()), httptest.NewRequest(http.MethodGet, "/", nil)) + assert.Equal(t, http.StatusNoContent, rec.Code) +} diff --git a/voter/composite.go b/voter/composite.go index 3130f39b..c8c35313 100644 --- a/voter/composite.go +++ b/voter/composite.go @@ -28,7 +28,9 @@ import ( // // And panics if it is given no voter or a nil one. func And(voters ...security.Voter) security.Voter { - return newComposite("And", composeAnd, voters) + return newComposite("And", voters, func(votes []security.Decision, _ security.Authentication) security.Decision { + return and(votes) + }) } // Or combines voters with disjunction semantics: @@ -39,7 +41,9 @@ func And(voters ...security.Voter) security.Voter { // // Or panics if it is given no voter or a nil one. func Or(voters ...security.Voter) security.Voter { - return newComposite("Or", composeOr, voters) + return newComposite("Or", voters, func(votes []security.Decision, _ security.Authentication) security.Decision { + return or(votes) + }) } // Not inverts an inner voter: Grant becomes Deny and Deny becomes Grant — but @@ -48,23 +52,22 @@ func Or(voters ...security.Voter) security.Voter { // does not admit anonymous callers (use [Anonymous] with // security.IsAnonymous). Abstain stays Abstain. Not panics on a nil voter. func Not(inner security.Voter) security.Voter { - return newComposite("Not", composeNot, []security.Voter{inner}) + return newComposite("Not", []security.Voter{inner}, func(votes []security.Decision, auth security.Authentication) security.Decision { + return not(votes[0], auth) + }) } -type composeMode int - -const ( - composeAnd composeMode = iota - composeOr - composeNot -) - type compositeVoter struct { voters []security.Voter - mode composeMode + // combine folds the inner votes into the composite's decision. + combine func(votes []security.Decision, auth security.Authentication) security.Decision } -func newComposite(constructor string, mode composeMode, voters []security.Voter) compositeVoter { +func newComposite( + constructor string, + voters []security.Voter, + combine func([]security.Decision, security.Authentication) security.Decision, +) compositeVoter { if len(voters) == 0 { panic("voter: " + constructor + ": no voter") } @@ -75,7 +78,7 @@ func newComposite(constructor string, mode composeMode, voters []security.Voter) } } - return compositeVoter{voters: slices.Clone(voters), mode: mode} + return compositeVoter{voters: slices.Clone(voters), combine: combine} } // Supports returns true when at least one inner voter does. @@ -89,28 +92,15 @@ func (c compositeVoter) Supports(a security.Attribute) bool { return false } -// Vote combines the inner votes according to the composite's mode. +// Vote combines the inner votes. func (c compositeVoter) Vote(ctx context.Context, auth security.Authentication, attrs []security.Attribute) security.Decision { - switch c.mode { - case composeNot: - return not(c.voters[0].Vote(ctx, auth, attrs), auth) - case composeAnd: - return and(c.votes(ctx, auth, attrs)) - case composeOr: - return or(c.votes(ctx, auth, attrs)) - } - - return security.DecisionDeny -} - -func (c compositeVoter) votes(ctx context.Context, auth security.Authentication, attrs []security.Attribute) []security.Decision { - out := make([]security.Decision, len(c.voters)) + votes := make([]security.Decision, len(c.voters)) for i, v := range c.voters { - out[i] = v.Vote(ctx, auth, attrs) + votes[i] = v.Vote(ctx, auth, attrs) } - return out + return c.combine(votes, auth) } // and is the strict conjunction: Grant only when every vote is a Grant. An From 343e6a7b4215b73f97828e1ee5481c0e260ed876 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:09:52 +0200 Subject: [PATCH 13/29] fix(core): deny empty decisions and fail closed on typed nils - Decide denies a decision without attributes (or without voters) whatever the strategy, tie-break and abstain fallback: a unanimous manager with WithAbstainFallback(DecisionGrant) granted a route without attributes to anyone, anonymous callers included. The fallback only decides for custom voters that support an attribute and abstain. - A typed nil (an interface holding a nil pointer) counts as nil in every contract check instead of panicking on its first method call: an authenticator or a Manager returning one breaks its contract, an extractor returning one found nothing, Decide treats one as Anonymous (or denies a typed-nil attribute), and FromContext reads one back as Anonymous. - The Manager abstains on a bail-out only when the error wraps no higher-ranked sentinel: ErrUnsupportedCredential joined with ErrUnavailable is an outage, not an abstention. - Tests pin the constructors' defensive copies, an extractor returning a value with its error, and identical repeated fields. - Docs: a negated voter decides the attributes it supports, so a decision manager holding Not(HasRole("X")) must not serve routes that require X. Audit: CORE-01, CORE-I3, CORE-15, CORE-I5. --- CHANGELOG.md | 20 ++- MIGRATION.md | 29 +++-- access_decision_manager.go | 61 +++++---- access_decision_manager_supported_test.go | 33 +++++ authenticator.go | 6 +- carrier_test.go | 6 + context.go | 12 +- context_test.go | 7 ++ contract_test.go | 146 ++++++++++++++++++++-- docs/security-considerations.md | 13 +- engine.go | 10 +- errors.go | 3 +- extractor.go | 3 +- manager.go | 17 +-- nil.go | 31 +++++ nil_internal_test.go | 53 ++++++++ 16 files changed, 377 insertions(+), 73 deletions(-) create mode 100644 nil.go create mode 100644 nil_internal_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d26cb36..564d0b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -629,9 +629,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree on every failure, and the Engine stores the anonymous authentication in the returned context: the extracted credentials never flow back to the caller. The `Manager` treats an authenticator error wrapping - `ErrUnsupportedCredential` as an abstention (the documented runtime - bail-out) rather than a refusal joined with the others, and a "success" - without an authenticated value as a refusal. `NewEngine`, `NewManager` + `ErrUnsupportedCredential` — and no higher-ranked sentinel — as an + abstention (the documented runtime bail-out) rather than a refusal joined + with the others, and a "success" without an authenticated value as a + refusal. A typed nil (an interface holding a nil pointer) counts as nil + wherever the core checks a contract. `NewEngine`, `NewManager` and the three `AccessDecisionManager` constructors panic on a nil dependency. See [MIGRATION.md](MIGRATION.md). - Failing core spans (`security.Engine.Process`, @@ -645,7 +647,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `security.IsAnonymous`, `voter.And` is strict (an inner abstention denies when the composite votes), `voter.Not` never grants an unauthenticated principal, and the decision managers deny an attribute that no voter - supports and pass each voter only the attributes it supports. `And`, `Or` + supports — and a decision without attributes or voters whatever the + tie-break and abstain fallback — and pass each voter only the attributes + it supports. `And`, `Or` and `Not` panic on a nil or missing voter. See [MIGRATION.md](MIGRATION.md). - Roles and scopes live in their own authority namespaces: `voter.HasRole` @@ -811,7 +815,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree a custom `Manager` returning no authenticated value without an error, a nil `Attribute`, a nil `Authentication` passed to `Decide`, and a voter returning an undefined `Decision` now fail closed (an error, a denial, the - anonymous authentication, a deny vote). + anonymous authentication, a deny vote) — typed nils included. An + extractor's typed nil is "no credential". - `grpcsec` sends the metadata staged on its `Carrier` as the response header, refused RPCs included, as its documentation said; it was dropped. A custom `Engine` returning no authentication no longer panics the @@ -1272,6 +1277,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - `voter.Not()` turned the refusal of an unauthenticated principal into a grant (`Not(HasRole("BANNED"))` admitted anonymous callers); it no longer does. +- `NewUnanimousDecisionManager` with `WithAbstainFallback(DecisionGrant)` + granted a decision without attributes — a route whose attribute lookup + returned nothing — and any decision of a manager without voters, to anyone, + anonymous callers included. Such decisions are now denied whatever the + strategy, tie-break and fallback. - `httpsec.Carrier` answered `Get`/`Values` from a cookie or from the query string when the header was absent: Basic and Bearer credentials were accepted from `?Authorization=…` and from a cookie named `Authorization` diff --git a/MIGRATION.md b/MIGRATION.md index e3dea7e1..9f32f31b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1267,8 +1267,8 @@ After a failed authentication, `Engine.Process` stored the extracted `Authentication` — a cleartext password, a bearer token — in the request context and returned it, and `Manager.Authenticate` returned it too. A component breaking its contract made the request path panic: an -authenticator returning `(nil, nil)`, a nil `Attribute`, a nil -`Authentication` passed to `Decide`. And an authenticator bailing out at +authenticator returning `(nil, nil)` or a typed nil (`var out *T; return +out, nil`), a nil `Attribute`, a nil `Authentication` passed to `Decide`. And an authenticator bailing out at runtime with `ErrUnsupportedCredential` — the documented way to do so — had its error joined with the other authenticators' refusals, so a wrong password could be answered as an unsupported credential. @@ -1276,12 +1276,15 @@ could be answered as an unsupported credential. | Situation | Before | After | | --- | --- | --- | | `Engine.Process` / `Manager.Authenticate` fail | return (and store) the extracted value | return (and store) `security.Anonymous()` | -| An authenticator returns `(nil, nil)` | panic | refusal; the next authenticator is tried | +| An authenticator returns `(nil, nil)` or a typed nil | panic | refusal; the next authenticator is tried | | An authenticator returns an unauthenticated value and no error | success | refusal (contract violation) | -| A custom `Manager` returns no authenticated value and no error | panic / success | `Process` fails | -| An authenticator returns an error wrapping `ErrUnsupportedCredential` | joined into `ErrAuthenticatorRefused` | abstention; `ErrUnsupportedCredential` when every supporting authenticator bails out | -| `Decide` with a nil `Attribute` | panic | `ErrAccessDenied` | -| `Decide` with a nil `Authentication` | panic | evaluated as `security.Anonymous()` | +| A custom `Manager` returns no authenticated value (nil, typed nil) and no error | panic / success | `Process` fails | +| An extractor returns a typed nil and no error | handed to the authenticators (panic) | no credential: the next extractor runs | +| An authenticator returns an error wrapping `ErrUnsupportedCredential` only | joined into `ErrAuthenticatorRefused` | abstention; `ErrUnsupportedCredential` when every supporting authenticator bails out | +| … wrapping `ErrUnsupportedCredential` and a verdict (`ErrInvalidCredentials`, `ErrUnavailable`, `ErrInvalidRequest`, …) | joined | that verdict (`security.ErrorClass` order) | +| `Decide` with a nil or typed nil `Attribute` | panic | `ErrAccessDenied` | +| `Decide` with a nil or typed nil `Authentication` | panic | evaluated as `security.Anonymous()` | +| `FromContext` after `WithAuthentication(ctx, typedNil)` | the typed nil | `security.Anonymous()`, `false` | | A voter returns an undefined `Decision` | ignored (abstention) | counted as `DecisionDeny` | | `NewEngine(nil, …)`, a nil extractor, authenticator or voter | panic on the first request | panic at construction | @@ -1325,6 +1328,7 @@ supports. | `Not(HasRole("BANNED"))`, anonymous on `[Role("BANNED")]` | granted | denied | | `And(HasRole("USER"), Not(HasRole("BANNED")))` on `[Role("USER")]` | banned users granted | denied for everyone — list `[Role("USER"), Role("BANNED")]` | | Unanimous / consensus with an attribute no voter supports | ignored | denied | +| Unanimous with `WithAbstainFallback(DecisionGrant)`, no attribute or no voter | granted, anonymous callers included | denied | | `voter.FullyAuthenticated()` | same as `Authenticated()` | removed | To migrate: @@ -1332,7 +1336,10 @@ To migrate: 1. Make every route's attributes name exactly what its voters check, and give the decision manager a voter for every attribute: `Authorize(adm, security.Role("ADMIN"))` with `voter.HasRole("ADMIN")` in `adm`. An - attribute that no voter supports now denies the request. + attribute that no voter supports now denies the request, and so does a + route without attributes, whatever the strategy, tie-break or + `WithAbstainFallback` (which only decides for custom voters that support + an attribute and abstain). 2. Require a logged-in or a logged-out caller with an attribute: `voter.Authenticated()` with `security.IsAuthenticated`, `voter.Anonymous()` with `security.IsAnonymous`. @@ -1341,8 +1348,10 @@ To migrate: route accepts among its attributes. 4. With `voter.And`, list one attribute per inner requirement: every inner voter must grant. `And(HasRole("USER"), Not(HasRole("BANNED")))` needs - `[Role("USER"), Role("BANNED")]`; do not share a decision manager holding - such a `Not(HasRole("X"))` policy with a positive `HasRole("X")` voter. + `[Role("USER"), Role("BANNED")]`. A `Role("X")` attribute is decided by + every voter supporting it, negated ones included: a decision manager + holding `Not(HasRole("X"))` grants `[Role("X")]` routes to principals + *without* X. Keep such a policy in its own decision manager. 5. Do not rely on `voter.Not` to admit anonymous callers: `Not(Authenticated())` never grants them — use `voter.Anonymous()` with `security.IsAnonymous`. diff --git a/access_decision_manager.go b/access_decision_manager.go index 51a4fdf5..e5c63c33 100644 --- a/access_decision_manager.go +++ b/access_decision_manager.go @@ -9,6 +9,8 @@ import ( "fmt" "slices" "strings" + + "go.opentelemetry.io/otel/trace" ) // AccessDecisionManager combines the verdicts of multiple [Voter]s into a @@ -22,15 +24,17 @@ import ( // [WithTieBreak](DecisionGrant) to flip the policy. // - Unanimous — every voter that does not abstain MUST grant. A single // deny refuses; if every voter abstains, the result depends on -// [WithAbstainFallback]. Unlike Spring's UnanimousBased, the voters see +// [WithAbstainFallback] (a voter abstains when it supports none of the +// attributes, or — a custom voter — when it supports one and abstains). Unlike Spring's UnanimousBased, the voters see // all their attributes at once: it is an AND across voters, while a // multi-name voter (voter.HasAnyRole) grants when the principal holds // any of the requested names — express an AND of roles with one voter // per role, or with voter.And. // -// Whatever the strategy, a decision is denied when one of its attributes is -// supported by no voter (a requirement nobody checks is never ignored), and -// when there is no attribute or no voter at all. +// Whatever the strategy, the tie-break and the abstain fallback, a decision +// is denied when one of its attributes is supported by no voter (a +// requirement nobody checks is never ignored), and when there is no +// attribute or no voter at all. // // Implementations are safe for concurrent use. type AccessDecisionManager interface { @@ -54,7 +58,10 @@ func WithTieBreak(d Decision) admOption { //nolint:revive // exported via constr } // WithAbstainFallback controls the verdict when every unanimous voter -// abstains. Default: DecisionDeny. +// abstains. Default: DecisionDeny. It never applies to a decision without +// attributes or without voters, which is denied; the stock voters never +// abstain on an attribute they support, so it only matters for custom +// voters that do. func WithAbstainFallback(d Decision) admOption { //nolint:revive // exported via constructors return func(c *admConfig) { c.abstainFallback = d } } @@ -114,7 +121,8 @@ type accessDecisionManager struct { // Contract violations fail closed: a nil auth is treated as [Anonymous], a // nil attribute denies the request before any voter runs, and a voter // returning a value other than [DecisionGrant] or [DecisionAbstain] is -// counted as [DecisionDeny]. +// counted as [DecisionDeny]. A typed nil (an interface holding a nil +// pointer) counts as nil. func (m *accessDecisionManager) Decide(ctx context.Context, auth Authentication, attrs []Attribute) error { ctx, span := tracer().Start(ctx, "security.AccessDecisionManager.Decide") defer span.End() @@ -124,19 +132,11 @@ func (m *accessDecisionManager) Decide(ctx context.Context, auth Authentication, AttrAttributes.String(joinAttributes(attrs)), ) - if auth == nil { + if isNil(auth) { auth = Anonymous() } - final := DecisionDeny - - if !containsNil(attrs) { - if unsupported := m.unsupported(attrs); unsupported > 0 { - span.SetAttributes(AttrAttributesUnsupported.Int(unsupported)) - } else { - final = m.decide(m.votes(ctx, auth, attrs)) - } - } + final := m.decision(ctx, span, auth, attrs) span.SetAttributes(AttrDecision.String(final.String())) @@ -149,6 +149,23 @@ func (m *accessDecisionManager) Decide(ctx context.Context, auth Authentication, return ErrAccessDenied } +// decision runs the strategy, or denies before it — whatever the tie-break +// and the abstain fallback — without attributes, on a nil attribute and on +// an attribute no voter supports (every attribute, without voters). +func (m *accessDecisionManager) decision(ctx context.Context, span trace.Span, auth Authentication, attrs []Attribute) Decision { + if len(attrs) == 0 || containsNil(attrs) { + return DecisionDeny + } + + if unsupported := m.unsupported(attrs); unsupported > 0 { + span.SetAttributes(AttrAttributesUnsupported.Int(unsupported)) + + return DecisionDeny + } + + return m.decide(m.votes(ctx, auth, attrs)) +} + // unsupported counts the attributes that no voter supports. func (m *accessDecisionManager) unsupported(attrs []Attribute) int { count := 0 @@ -273,15 +290,9 @@ func unanimous(allAbstainFallback Decision) func([]Decision) Decision { } } -// containsNil reports whether attrs holds a nil Attribute. +// containsNil reports whether attrs holds a nil (or typed nil) Attribute. func containsNil(attrs []Attribute) bool { - for _, a := range attrs { - if a == nil { - return true - } - } - - return false + return slices.ContainsFunc(attrs, func(a Attribute) bool { return isNil(a) }) } // supportedBy returns the attributes v supports, in order. @@ -304,7 +315,7 @@ func joinAttributes(attrs []Attribute) string { parts := make([]string, len(attrs)) for i, a := range attrs { - if a == nil { + if isNil(a) { parts[i] = "" continue diff --git a/access_decision_manager_supported_test.go b/access_decision_manager_supported_test.go index 28659969..0ddee5e6 100644 --- a/access_decision_manager_supported_test.go +++ b/access_decision_manager_supported_test.go @@ -108,6 +108,39 @@ func TestEveryStrategyDeniesByDefault(t *testing.T) { } } +// Review finding 1 (user decision): no attribute or no voter denies whatever +// the strategy, tie-break and abstain fallback; the fallback only decides +// for voters that support an attribute and abstain. +func TestDecideDeniesWithoutAttributesOrVotersWhateverTheFallback(t *testing.T) { + t.Parallel() + + abstain := &scriptedVoter{prefix: "scope:", vote: security.DecisionAbstain} + grant := &scriptedVoter{prefix: "scope:", vote: security.DecisionGrant} + + for name, adm := range map[string]security.AccessDecisionManager{ + "unanimous, fallback grant": security.NewUnanimousDecisionManager([]security.Voter{abstain}, + security.WithAbstainFallback(security.DecisionGrant)), + "unanimous, fallback grant, granting voter": security.NewUnanimousDecisionManager([]security.Voter{grant}, + security.WithAbstainFallback(security.DecisionGrant)), + "unanimous, fallback grant, no voters": security.NewUnanimousDecisionManager(nil, + security.WithAbstainFallback(security.DecisionGrant)), + "consensus, tie-break grant, no voters": security.NewConsensusDecisionManager(nil, + security.WithTieBreak(security.DecisionGrant)), + "affirmative, granting voter": security.NewAffirmativeDecisionManager(grant), + } { + for attrsName, attrs := range map[string][]security.Attribute{"nil attrs": nil, "empty attrs": {}} { + for _, auth := range []security.Authentication{security.Anonymous(), newFakeAuth("alice").withAuthenticated()} { + err := adm.Decide(context.Background(), auth, attrs) + assert.ErrorIs(t, err, security.ErrAccessDenied, "%s, %s, authenticated=%v", name, attrsName, auth.IsAuthenticated()) + } + } + } + + noVoters := security.NewUnanimousDecisionManager(nil, security.WithAbstainFallback(security.DecisionGrant)) + assert.ErrorIs(t, noVoters.Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), + []security.Attribute{stringAttr("scope:read")}), security.ErrAccessDenied) +} + func TestConsensusDeniesOnAMajorityOfDenies(t *testing.T) { t.Parallel() diff --git a/authenticator.go b/authenticator.go index db3ca002..5c605c39 100644 --- a/authenticator.go +++ b/authenticator.go @@ -28,10 +28,12 @@ import "context" // not be checked, they are not refused; // - [ErrUnsupportedCredential] is the canonical way to bail out at runtime // when Supports returned true but the value was nonetheless out of -// scope: the [Manager] counts it as an abstention. +// scope: the [Manager] counts it as an abstention — unless the error +// also wraps one of the sentinels above, which then prevails. // // An error wrapping no sentinel, or a "success" without an authenticated -// value, is a contract violation the transports answer as an internal error. +// value (nil, a typed nil or an unauthenticated value), is a contract +// violation the transports answer as an internal error. // // Implementations MUST be safe for concurrent use. type Authenticator interface { diff --git a/carrier_test.go b/carrier_test.go index c1acd920..938b105a 100644 --- a/carrier_test.go +++ b/carrier_test.go @@ -39,4 +39,10 @@ func TestSingleValue(t *testing.T) { require.ErrorIs(t, err, security.ErrInvalidRequest) assert.Empty(t, v) assert.Equal(t, `security: header field "Authorization" occurs 2 times: security: invalid authentication request`, err.Error()) + + // Identical repetitions are no less a repeated singleton field. + c.Set("Authorization", "Bearer one") + c.Add("Authorization", "Bearer one") + _, err = security.SingleValue(c, "Authorization") + require.ErrorIs(t, err, security.ErrInvalidRequest) } diff --git a/context.go b/context.go index 5aae819c..19b4f94c 100644 --- a/context.go +++ b/context.go @@ -15,19 +15,19 @@ type authCtxKey struct{} // calls overwrite the previous value; this is the expected behavior when an // authenticator promotes an unauthenticated value to an authenticated one. // -// Passing a nil Authentication clears the slot — useful for "logout" -// middlewares. +// Passing a nil Authentication — or a typed nil, an interface holding a nil +// pointer — clears the slot: useful for "logout" middlewares. func WithAuthentication(ctx context.Context, auth Authentication) context.Context { return context.WithValue(ctx, authCtxKey{}, auth) } // FromContext returns the [Authentication] stored in ctx and a boolean -// indicating whether one was present. When the slot is empty, it returns -// the anonymous authentication (see [Anonymous]) so callers can rely on a -// non-nil value without a nil check. +// indicating whether one was present. When the slot is empty (or holds a +// typed nil), it returns the anonymous authentication (see [Anonymous]) so +// callers can rely on a usable value without a nil check. func FromContext(ctx context.Context) (Authentication, bool) { v, ok := ctx.Value(authCtxKey{}).(Authentication) - if !ok || v == nil { + if !ok || isNil(v) { return Anonymous(), false } diff --git a/context_test.go b/context_test.go index 7be4f013..e2513690 100644 --- a/context_test.go +++ b/context_test.go @@ -47,6 +47,13 @@ func TestWithAuthenticationNilClearsTheSlot(t *testing.T) { assert.False(t, ok) assert.Equal(t, security.Anonymous(), got) + + // A typed nil reads back as absent too: callers never get a value that + // panics on its first method call. + got, ok = security.FromContext(security.WithAuthentication(context.Background(), typedNilAuth)) + + assert.False(t, ok) + assert.Equal(t, security.Anonymous(), got) } func TestWithAuthenticationOverwrites(t *testing.T) { diff --git a/contract_test.go b/contract_test.go index 1cadfa2a..1f915cd9 100644 --- a/contract_test.go +++ b/contract_test.go @@ -38,6 +38,27 @@ func (v constDecisionVoter) Vote(context.Context, security.Authentication, []sec return v.d } +// derefAuth reads a field in every method: a typed nil — (*derefAuth)(nil), +// the classic `var out *T; return out, nil` — satisfies Authentication but +// panics on its first method call. +type derefAuth struct{ name string } + +func (a *derefAuth) Principal() security.Principal { return fakePrincipal{subject: a.name} } +func (a *derefAuth) Credentials() any { return a.name } +func (a *derefAuth) Authorities() []string { return []string{a.name} } +func (a *derefAuth) IsAuthenticated() bool { return a.name != "" } +func (a *derefAuth) Name() string { return a.name } + +// derefAttr is the Attribute counterpart of derefAuth. +type derefAttr struct{ name string } + +func (a *derefAttr) String() string { return a.name } + +var ( + typedNilAuth security.Authentication = (*derefAuth)(nil) + typedNilAttr security.Attribute = (*derefAttr)(nil) +) + // scriptedManager is a custom Manager returning a fixed result. type scriptedManager struct { result security.Authentication @@ -108,6 +129,7 @@ func TestEngineFailsClosedWhenAnAuthenticatorReturnsNoAuthenticatedValue(t *test cases := map[string]security.Authentication{ "nil result": nil, + "typed nil result": typedNilAuth, "unauthenticated result": newFakeAuth("alice").withCredentials("pw"), } @@ -150,6 +172,7 @@ func TestEngineFailsClosedWhenTheManagerReturnsNoAuthenticatedValue(t *testing.T cases := map[string]security.Authentication{ "nil result": nil, + "typed nil result": typedNilAuth, "unauthenticated result": newFakeAuth("alice").withCredentials("pw"), } @@ -265,6 +288,24 @@ func TestManagerTreatsARuntimeBailOutAsAnAbstention(t *testing.T) { assert.NotErrorIs(t, err, security.ErrAuthenticatorRefused) }) + // Review nit 6: a bail-out that also wraps a higher-ranked sentinel is + // that verdict, not an abstention (security.ErrorClass order). + t.Run("bail-out wrapping a verdict", func(t *testing.T) { + t.Parallel() + + for _, verdict := range []error{security.ErrInvalidRequest, security.ErrUnavailable, security.ErrInvalidCredentials} { + m := security.NewManager(&scriptedAuthenticator{ + name: "joined", + err: fmt.Errorf("%w: %w", security.ErrUnsupportedCredential, verdict), + }) + + _, err := m.Authenticate(context.Background(), newFakeAuth("alice")) + + require.ErrorIs(t, err, security.ErrAuthenticatorRefused, verdict.Error()) + assert.Equal(t, security.ErrorClass(verdict), security.ErrorClass(err), verdict.Error()) + } + }) + t.Run("bail-out then success", func(t *testing.T) { t.Parallel() @@ -283,6 +324,7 @@ func TestManagerTreatsAMissingOrUnauthenticatedResultAsARefusal(t *testing.T) { for name, result := range map[string]security.Authentication{ "nil result": nil, + "typed nil result": typedNilAuth, "unauthenticated result": newFakeAuth("alice"), } { t.Run(name, func(t *testing.T) { @@ -290,7 +332,11 @@ func TestManagerTreatsAMissingOrUnauthenticatedResultAsARefusal(t *testing.T) { violator := &scriptedAuthenticator{name: "violator", result: result} - _, err := security.NewManager(violator).Authenticate(context.Background(), newFakeAuth("alice")) + var err error + + require.NotPanics(t, func() { + _, err = security.NewManager(violator).Authenticate(context.Background(), newFakeAuth("alice")) + }) require.Error(t, err) assert.ErrorIs(t, err, security.ErrAuthenticatorRefused) @@ -316,9 +362,12 @@ func TestDecideDeniesANilAttribute(t *testing.T) { admin := newFakeAuth("root", "ROLE_ADMIN").withAuthenticated() for name, attrs := range map[string][]security.Attribute{ - "only nil": {nil}, - "nil after valid": {stringAttr("role:ADMIN"), nil}, - "nil before valid": {nil, stringAttr("role:ADMIN")}, + "only nil": {nil}, + "nil after valid": {stringAttr("role:ADMIN"), nil}, + "nil before valid": {nil, stringAttr("role:ADMIN")}, + "only typed nil": {typedNilAttr}, + "typed nil after valid": {stringAttr("role:ADMIN"), typedNilAttr}, + "typed nil before valid": {typedNilAttr, stringAttr("role:ADMIN")}, } { var err error @@ -332,12 +381,93 @@ func TestDecideTreatsANilAuthenticationAsAnonymous(t *testing.T) { adm := security.NewAffirmativeDecisionManager(authReadingVoter{}) - var err error + for name, auth := range map[string]security.Authentication{"nil": nil, "typed nil": typedNilAuth} { + var err error + + require.NotPanics(t, func() { + err = adm.Decide(context.Background(), auth, []security.Attribute{stringAttr("x")}) + }, name) + assert.ErrorIs(t, err, security.ErrAccessDenied, name) + } +} + +// A typed nil extracted value is the extractor's "nothing found" written +// with a nil pointer: the Engine goes on with the next extractor and never +// hands it to the Manager. +func TestEngineTreatsATypedNilExtractionAsNoCredential(t *testing.T) { + t.Parallel() + + authenticator := &scriptedAuthenticator{name: "any", result: newFakeAuth("mallory").withAuthenticated()} + next := &countingExtractor{} + engine := security.NewEngine(security.NewManager(authenticator), scriptedExtractor{auth: typedNilAuth}, next) - require.NotPanics(t, func() { - err = adm.Decide(context.Background(), nil, []security.Attribute{stringAttr("x")}) + var ( + ctx context.Context + auth security.Authentication + err error + ) + + require.NotPanics(t, func() { ctx, auth, err = engine.Process(context.Background(), newMapCarrier()) }) + + require.NoError(t, err) + assert.Equal(t, security.Anonymous(), auth) + assert.Equal(t, 1, next.calls, "the next extractor is consulted") + assert.Zero(t, authenticator.calls(), "nothing was extracted") + + stored, _ := security.FromContext(ctx) + assert.Equal(t, security.Anonymous(), stored) +} + +// Mutant R71: an extractor returning a value AND an error must not see +// that value stored in the context. +func TestEngineExtractorFailureLeavesNoCredentials(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(security.NewManager(), scriptedExtractor{ + auth: newFakeAuth("alice").withCredentials("S3cr3t!"), + err: fmt.Errorf("malformed: %w", security.ErrInvalidRequest), }) - assert.ErrorIs(t, err, security.ErrAccessDenied) + + ctx, got, err := engine.Process(context.Background(), newMapCarrier()) + + require.ErrorIs(t, err, security.ErrInvalidRequest) + assert.Equal(t, security.Anonymous(), got) + + stored, _ := security.FromContext(ctx) + assert.Equal(t, security.Anonymous(), stored) +} + +// Mutants R18-R20: the constructors copy the slices they are given, so the +// caller reusing its slice cannot rewire a running Engine, Manager or +// decision manager. +func TestConstructorsCopyTheirSlices(t *testing.T) { + t.Parallel() + + alice := newFakeAuth("alice").withAuthenticated() + + extractors := []security.Extractor{scriptedExtractor{auth: newFakeAuth("alice")}} + authenticators := []security.Authenticator{&scriptedAuthenticator{name: "ok", result: alice}} + engine := security.NewEngine(security.NewManager(authenticators...), extractors...) + + extractors[0] = scriptedExtractor{err: security.ErrInvalidRequest} + authenticators[0] = &scriptedAuthenticator{name: "ko", err: security.ErrInvalidCredentials} + + _, got, err := engine.Process(context.Background(), newMapCarrier()) + require.NoError(t, err) + assert.Equal(t, security.Authentication(alice), got) + + voters := []security.Voter{constDecisionVoter{d: security.DecisionGrant}} + adms := map[string]security.AccessDecisionManager{ + "affirmative": security.NewAffirmativeDecisionManager(voters...), + "consensus": security.NewConsensusDecisionManager(voters), + "unanimous": security.NewUnanimousDecisionManager(voters), + } + + voters[0] = constDecisionVoter{d: security.DecisionDeny} + + for name, adm := range adms { + assert.NoError(t, adm.Decide(context.Background(), alice, []security.Attribute{stringAttr("x")}), name) + } } func TestDecideCountsAnUndefinedDecisionAsDeny(t *testing.T) { diff --git a/docs/security-considerations.md b/docs/security-considerations.md index f4178a35..be05b66a 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1135,15 +1135,20 @@ Authorization follows Spring Security's voter model, and fails closed: can never pass for a role. - **No requirement is ignored.** A decision is denied when one of its attributes is supported by no voter of the decision manager, when there - is no attribute, and when there is no voter. + is no attribute, and when there is no voter — whatever the strategy, the + tie-break and the abstain fallback: `WithAbstainFallback` only decides + for custom voters that support an attribute and abstain. - **Composites.** `voter.And` grants only when every inner voter grants — an inner voter whose attribute the route does not list makes it deny, so list one attribute per inner requirement. `voter.Not` never turns the refusal of an unauthenticated principal into a grant: `Not(HasRole("BANNED"))` admits authenticated principals without the role, not anonymous callers. - Do not share a decision manager holding a `Not(HasRole("X"))` policy with - a positive `HasRole("X")` voter: under the affirmative strategy the - positive voter would grant `Role("X")` routes. + A `Role("X")` attribute is decided by every voter that supports it, + negated ones included: a decision manager holding `Not(HasRole("X"))` + grants a `[Role("X")]` route to the authenticated principals *without* + X, and next to a positive `HasRole("X")` voter the affirmative strategy + grants it to those *with* X too. Keep a negated policy in its own + decision manager, used only by the routes it was written for. - **Strategies.** Affirmative grants on one satisfied attribute (OR); unanimous requires every voting voter to grant (AND across voters), but a multi-name voter such as `HasAnyRole` grants when the principal holds any diff --git a/engine.go b/engine.go index 64b2206c..e072d911 100644 --- a/engine.go +++ b/engine.go @@ -25,7 +25,8 @@ var errContractViolation = errors.New("security: contract violation") // Engine is safe for concurrent use. type Engine interface { // Process runs extractors in order and consults the manager on the first - // non-empty result. The returned context always carries an + // non-empty result — a typed nil (an interface holding a nil pointer) + // counts as empty. The returned context always carries an // Authentication: the authenticated one on success, the anonymous one // when nothing was extracted and on every failure — the extracted // credentials never leave the Engine. On failure the error says why. @@ -98,7 +99,7 @@ func (e *engine) Process(ctx context.Context, c Carrier) (context.Context, Authe return ctx, Anonymous(), err } - if auth != nil { + if !isNil(auth) { extracted = auth break @@ -137,9 +138,10 @@ func (e *engine) Process(ctx context.Context, c Carrier) (context.Context, Authe } // requireAuthenticated reports a contract violation when a component that -// returned no error produced no authenticated value. +// returned no error produced no authenticated value: nil, a typed nil or an +// unauthenticated value. func requireAuthenticated(auth Authentication, component string) error { - if auth == nil || !auth.IsAuthenticated() { + if isNil(auth) || !auth.IsAuthenticated() { return fmt.Errorf("%w: %s returned no authenticated value", errContractViolation, component) } diff --git a/errors.go b/errors.go index 15f4adbc..6d2286a9 100644 --- a/errors.go +++ b/errors.go @@ -46,7 +46,8 @@ var ( // validate: it maps to HTTP 401 with a challenge that carries no error // code (RFC 6750 §3.1, RFC 9110 §15.5.2) and to gRPC/Connect // Unauthenticated. An [Authenticator] returning it from Authenticate - // bails out: the [Manager] counts that as an abstention, not a refusal. + // bails out: the [Manager] counts that as an abstention, not a refusal — + // unless the error also wraps a higher-ranked sentinel ([ErrorClass]). ErrUnsupportedCredential = newSentinel("security: unsupported credential type") // ErrNoExtractor indicates that the [Engine] was configured without any diff --git a/extractor.go b/extractor.go index d44632d2..6f49c9ee 100644 --- a/extractor.go +++ b/extractor.go @@ -14,7 +14,8 @@ import "context" // // - Return (nil, nil) when no credentials of the supported scheme are // present. The Engine treats this as "this extractor does not apply" -// and consults the next one. +// and consults the next one; a typed nil (an interface holding a nil +// pointer) counts as nil. // - Return (nil, err) wrapping a security sentinel when credentials were // present but malformed (e.g. invalid base64 in Basic). The Engine // surfaces err to the caller and stops; downstream authenticators are diff --git a/manager.go b/manager.go index 5bcc862e..bed3f54d 100644 --- a/manager.go +++ b/manager.go @@ -17,11 +17,13 @@ import ( // - Each authenticator whose Supports() returns true is invoked. // - On success — an authenticated value and no error — the result is // returned immediately; subsequent authenticators are NOT consulted. -// - An error wrapping [ErrUnsupportedCredential] is a runtime bail-out: -// the authenticator abstains, as if Supports had returned false. -// - Any other error, and a success without an authenticated value (a -// contract violation), is a refusal: the next supporting authenticator -// is tried. If every one refuses, the refusals are joined and wrapped in +// - An error whose [ErrorClass] is [ErrorClassUnsupportedCredential] — it +// wraps [ErrUnsupportedCredential] and no higher-ranked sentinel — is a +// runtime bail-out: the authenticator abstains, as if Supports had +// returned false. +// - Any other error, and a success without an authenticated value (nil, +// a typed nil or an unauthenticated value: a contract violation), is a +// refusal: the next supporting authenticator is tried. If every one refuses, the refusals are joined and wrapped in // [ErrAuthenticatorRefused]. // - If no authenticator supports the credential (or all bail out), // [ErrUnsupportedCredential] is returned: the transports answer it like @@ -87,8 +89,9 @@ func (m *manager) Authenticate(ctx context.Context, auth Authentication) (Authen return result, nil } - if errors.Is(err, ErrUnsupportedCredential) { - // Runtime bail-out: the authenticator abstains. + if ErrorClass(err) == ErrorClassUnsupportedCredential { + // Runtime bail-out: the authenticator abstains. An error that + // also wraps a verdict or an outage is that verdict or outage. continue } diff --git a/nil.go b/nil.go new file mode 100644 index 00000000..b624cfa5 --- /dev/null +++ b/nil.go @@ -0,0 +1,31 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package security + +import ( + "reflect" + "slices" +) + +// nilableKinds are the kinds whose zero value is a nil a method call can +// dereference. +var nilableKinds = []reflect.Kind{ + reflect.Pointer, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan, reflect.UnsafePointer, +} + +// isNil reports whether v is nil or a typed nil — an interface holding a +// nil pointer, map, slice, function or channel, the classic +// `var out *T; return out, nil`. A typed nil satisfies an interface but +// panics on the first method that dereferences it: the contract checks +// treat it as nil, so it fails closed instead. +func isNil(v any) bool { + if v == nil { + return true + } + + rv := reflect.ValueOf(v) + + return slices.Contains(nilableKinds, rv.Kind()) && rv.IsNil() +} diff --git a/nil_internal_test.go b/nil_internal_test.go new file mode 100644 index 00000000..89d44f8a --- /dev/null +++ b/nil_internal_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package security + +import ( + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" +) + +func TestIsNil(t *testing.T) { + t.Parallel() + + type ( + ptr *int + fn func() + dict map[string]int + list []int + queue chan int + name string + ) + + x := 1 + + cases := []struct { + name string + v any + want bool + }{ + {"untyped nil", nil, true}, + {"nil pointer", ptr(nil), true}, + {"nil func", fn(nil), true}, + {"nil map", dict(nil), true}, + {"nil slice", list(nil), true}, + {"nil channel", queue(nil), true}, + {"nil unsafe pointer", unsafe.Pointer(nil), true}, + {"pointer", &x, false}, + {"func", fn(func() {}), false}, + {"map", dict{}, false}, + {"slice", list{}, false}, + {"channel", make(queue), false}, + {"string", name(""), false}, + {"struct", struct{}{}, false}, + {"zero int", 0, false}, + } + + for _, c := range cases { + assert.Equal(t, c.want, isNil(c.v), c.name) + } +} From dbb5d4b354efbb79a25fa0f9b5777d5dc1640c6d Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:09:52 +0200 Subject: [PATCH 14/29] test(voter): pin the copies of names and composite voters HasAnyRole and the composites copy the slices they are given: a caller reusing its slice for the next route cannot rewire a voter in use. Audit: CORE-01. --- voter/copies_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 voter/copies_test.go diff --git a/voter/copies_test.go b/voter/copies_test.go new file mode 100644 index 00000000..051d346e --- /dev/null +++ b/voter/copies_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package voter_test + +import ( + "context" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/voter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The constructors copy the names and voters they are given: a caller +// reusing its slice for the next route cannot rewire a voter already in use. +func TestConstructorsCopyTheirSlices(t *testing.T) { + t.Parallel() + + ctx := context.Background() + admin := newAuth("root", "ROLE_ADMIN") + user := newAuth("alice", "ROLE_USER") + + names := []string{"ADMIN"} + byName := security.NewAffirmativeDecisionManager(voter.HasAnyRole(names...)) + names[0] = "USER" + + voters := []security.Voter{voter.HasRole("ADMIN")} + composite := security.NewAffirmativeDecisionManager(voter.Or(voters...)) + voters[0] = voter.HasRole("USER") + + for name, adm := range map[string]security.AccessDecisionManager{"names": byName, "composite": composite} { + require.NoError(t, adm.Decide(ctx, admin, []security.Attribute{security.Role("ADMIN")}), name) + assert.ErrorIs(t, adm.Decide(ctx, user, []security.Attribute{security.Role("USER")}), security.ErrAccessDenied, name) + } +} From d3cc78d514984c51047480eec4f30a15e5b815b9 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:15:17 +0200 Subject: [PATCH 15/29] fix(http): challenge every Bearer refusal and refuse nil engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RFC 6750 §3: a Bearer resource answering a request without an access token that enables access MUST send WWW-Authenticate. The default mapper now challenges every refusal of a Bearer resource, 400 and 5xx included, with the RFC 6750 error code only for a request that carried a bearer token; other schemes challenge the 401 and the 403. - Middleware panics at construction on a nil engine, and answers a custom Engine's typed-nil Authentication 500 instead of panicking. - Authorize and AuthorizeWith copy their attributes and panic at construction on a nil (or typed-nil) attribute: reusing the slice for the next route no longer rewrites the first route's policy. - Tests pin the anonymous ErrInsufficientScope refusal, the nil-context guard, a bearer token in any Authorization field and the lowercase Basic charset. - LIMITATIONS: one challenge scheme per middleware; Basic credentials are not NFC-normalized under charset="UTF-8". Audit: CORE-10, CORE-I3. --- CHANGELOG.md | 9 +- LIMITATIONS.md | 10 ++ MIGRATION.md | 9 +- docs/security-considerations.md | 11 ++- http/authorize.go | 29 ++++-- http/challenge_test.go | 16 ++-- http/error_mapper.go | 45 +++++---- http/hardening_test.go | 160 ++++++++++++++++++++++++++++++++ http/mapping_test.go | 4 +- http/middleware.go | 13 ++- http/nil.go | 32 +++++++ http/nil_internal_test.go | 53 +++++++++++ 12 files changed, 337 insertions(+), 54 deletions(-) create mode 100644 http/hardening_test.go create mode 100644 http/nil.go create mode 100644 http/nil_internal_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 564d0b74..5b9e5088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -675,8 +675,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree credential is 401 without error code (was 400), an unauthenticated principal refused by `Authorize` 401 (was 403), a repeated `Authorization` field 400, an `ErrUnavailable` 503, and an error wrapping no sentinel 500 - (was 401). `Authorize` answers with the challenge of the `Middleware` in - front of it instead of a hard-coded bare `Bearer`. See + (was 401); a Bearer resource challenges every refusal, 400 and 5xx + included (RFC 6750 §3). `Authorize` answers with the challenge of the + `Middleware` in front of it instead of a hard-coded bare `Bearer`, copies + its attributes and panics on a nil one; `Middleware` panics on a nil + engine. See [MIGRATION.md](MIGRATION.md). - The `httpsec.Middleware` span records `http.request.method` (`_OTHER` for a method outside RFC 9110 / RFC 5789) instead of `http.method`, and @@ -817,6 +820,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree returning an undefined `Decision` now fail closed (an error, a denial, the anonymous authentication, a deny vote) — typed nils included. An extractor's typed nil is "no credential". +- A custom `Engine` returning a typed nil `Authentication` made + `httpsec.Middleware` panic; it is now answered 500, like a nil one. - `grpcsec` sends the metadata staged on its `Carrier` as the response header, refused RPCs included, as its documentation said; it was dropped. A custom `Engine` returning no authentication no longer panics the diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 7677192c..d971f153 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -346,6 +346,16 @@ PKCE. Not supported, and refused where a provider could select them: (`connectrpcsec`) adapters are shipped. Other transports can be added downstream by implementing `security.Carrier` (header fields only — never the URL or cookies) and mapping errors with `security.ErrorClass`. +- **One challenge scheme per middleware** — the default error mappers + advertise the one scheme configured with `WithChallengeScheme`, even when + the engine accepts several (Basic and Bearer): RFC 9110 §11.6.1 allows a + 401 to list several challenges, but a client is only told about that + scheme. Use one middleware per scheme, or a custom `ErrorMapper` that + writes every challenge. +- **Basic credentials are not NFC-normalized yet** — a Basic challenge + advertises `charset="UTF-8"`, which per RFC 7617 §2.1 also says the server + expects the user-id and password in Unicode Normalization Form C; `basic` + checks them as received. ## Sessions diff --git a/MIGRATION.md b/MIGRATION.md index 9f32f31b..898f7886 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1444,10 +1444,13 @@ and `401` to an infrastructure failure. The mapping now comes from | Wrong password next to an authenticator bailing out | `400`, no challenge | `401` with challenge | | Authenticated principal refused by `Authorize` | `403 Bearer` | `403 S realm=R` (`error="insufficient_scope"` for a bearer request) | | Unauthenticated principal refused by `Authorize` | `403 Bearer` | `401` with the `Middleware`'s challenge | -| Repeated `Authorization` field | first value used | `400` (`invalid_request` for a bearer request) | -| `ErrUnavailable` (store, key set down) | `401` | `503` | -| Error wrapping no sentinel, `ErrNoExtractor` | `401` | `500` | +| Repeated `Authorization` field | first value used | `400` with the challenge for a Bearer resource (`invalid_request` for a bearer request) | +| `ErrUnavailable` (store, key set down) | `401` | `503` (with a bare challenge for a Bearer resource) | +| Error wrapping no sentinel, `ErrNoExtractor`, a custom `Engine` returning no context or a typed nil | `401` / panic | `500` (with a bare challenge for a Bearer resource) | | Realm with a control or non-ASCII character | Go-escaped | `Middleware` / `DefaultErrorMapper` panic | +| `Middleware(nil)` | panic on the first request | panic at construction | +| `Authorize(adm, …, nil)` (a nil or typed nil attribute) | `403` on every request | panic at construction | +| Reusing the attribute slice passed to `Authorize` | rewrote the route's policy | no effect (copied) | To migrate: diff --git a/docs/security-considerations.md b/docs/security-considerations.md index be05b66a..8824529b 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1164,12 +1164,12 @@ outage never reads as a verdict on the credentials: | Class | HTTP | `WWW-Authenticate` (scheme S, realm R) | gRPC / ConnectRPC | | --- | --- | --- | --- | -| `invalid_request` (e.g. repeated `Authorization`) | 400 | Bearer + bearer token sent: `error="invalid_request"` | `InvalidArgument` | -| `unavailable` (`ErrUnavailable`) | 503 | — | `Unavailable` | +| `invalid_request` (e.g. repeated `Authorization`) | 400 | Bearer: `Bearer realm=R`; + bearer token sent: `error="invalid_request"` | `InvalidArgument` | +| `unavailable` (`ErrUnavailable`) | 503 | Bearer: `Bearer realm=R` | `Unavailable` | | `token_expired`, `token_not_found`, `client_secret_mismatch`, `invalid_credentials` | 401 | `S realm=R`; Bearer + bearer token sent: `error="invalid_token"` (RFC 9068 §4) | `Unauthenticated` | | `authentication_required`, `unsupported_credential` | 401 | `S realm=R` — no error code (RFC 6750 §3.1) | `Unauthenticated` | | `insufficient_scope`, `access_denied` | 403 | `S realm=R`; Bearer + bearer token sent: `error="insufficient_scope"` | `PermissionDenied` | -| `no_extractor`, `_OTHER` (no sentinel: a broken contract) | 500 | — | `Internal` | +| `no_extractor`, `_OTHER` (no sentinel: a broken contract) | 500 | Bearer: `Bearer realm=R` | `Internal` | A ConnectRPC error travels over HTTP with the status of the HTTP column, and the `connectrpcsec` default mapper puts the same `WWW-Authenticate` @@ -1179,7 +1179,10 @@ every Connect 401 carries one. Every 401 carries a challenge with a realm (`api` by default; RFC 9110 §11.6.1, RFC 7617 §2, RFC 6750 §3), a Basic one also `charset="UTF-8"` (RFC 7617 §2.1), and the realm is sent as an RFC 9110 quoted-string — a -realm a quoted-string cannot carry is refused at startup. An unauthenticated +realm a quoted-string cannot carry is refused at startup. A Bearer resource +challenges every refusal, 400 and 5xx included: RFC 6750 §3 requires the +challenge whenever the request carried no access token that enables +access. An unauthenticated principal refused by `httpsec.Authorize` is asked to authenticate (401), an authenticated one refused (403) (RFC 9110 §15.5.2, §15.5.4); `Authorize` answers with the challenge of the `Middleware` in front of it. The RFC 6750 diff --git a/http/authorize.go b/http/authorize.go index 52a62d64..6cd0b9cc 100644 --- a/http/authorize.go +++ b/http/authorize.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "slices" "github.com/hyperscale-stack/security" ) @@ -21,12 +22,9 @@ import ( // or, without one, [DefaultErrorMapper]("Bearer", [DefaultRealm]): 403 for an // authenticated principal, 401 with a challenge for an unauthenticated one // (RFC 9110 §15.5.2, §15.5.4: it lacks credentials, it is asked for them). -// Authorize panics if adm is nil. +// Authorize copies attrs, and panics if adm or one of attrs is nil (a typed +// nil included): a route's policy is static configuration. func Authorize(adm security.AccessDecisionManager, attrs ...security.Attribute) func(http.Handler) http.Handler { - if adm == nil { - panic("httpsec: Authorize: nil AccessDecisionManager") - } - return AuthorizeWith(adm, nil, attrs...) } @@ -38,10 +36,7 @@ func AuthorizeWith( mapper ErrorMapper, attrs ...security.Attribute, ) func(http.Handler) http.Handler { - if adm == nil { - panic("httpsec: Authorize: nil AccessDecisionManager") - } - + attrs = mustPolicy(adm, attrs) fallback := DefaultErrorMapper(defaultChallengeScheme, DefaultRealm) return func(next http.Handler) http.Handler { @@ -67,6 +62,22 @@ func AuthorizeWith( } } +// mustPolicy checks the static configuration of [Authorize] and returns a +// copy of attrs, so the caller reusing its slice cannot rewrite the policy. +func mustPolicy(adm security.AccessDecisionManager, attrs []security.Attribute) []security.Attribute { + if adm == nil { + panic("httpsec: Authorize: nil AccessDecisionManager") + } + + for i, a := range attrs { + if isNil(a) { + panic(fmt.Sprintf("httpsec: Authorize: nil Attribute at index %d", i)) + } + } + + return slices.Clone(attrs) +} + // refusal turns an access denial of an unauthenticated principal into // "authentication required": the caller lacks credentials and is asked for // them (401) rather than refused (403). Any other error passes through. diff --git a/http/challenge_test.go b/http/challenge_test.go index 516e28f4..daa5070d 100644 --- a/http/challenge_test.go +++ b/http/challenge_test.go @@ -59,9 +59,10 @@ func TestDefaultErrorMapperAnswers(t *testing.T) { challenge []string }{ {"invalid request, bearer", "Bearer", security.ErrInvalidRequest, bearerTokenSent, http.StatusBadRequest, challenge(invalidRequest)}, - {"invalid request, no token", "Bearer", security.ErrInvalidRequest, "", http.StatusBadRequest, nil}, - {"unavailable", "Bearer", security.ErrUnavailable, bearerTokenSent, http.StatusServiceUnavailable, nil}, - {"unavailable joined with a verdict", "Bearer", refused(security.ErrTokenNotFound, security.ErrUnavailable), bearerTokenSent, http.StatusServiceUnavailable, nil}, + {"invalid request, no token", "Bearer", security.ErrInvalidRequest, "", http.StatusBadRequest, challenge(bearerAPI)}, + {"invalid request, basic credentials", "Bearer", security.ErrInvalidRequest, "Basic dTpw", http.StatusBadRequest, challenge(bearerAPI)}, + {"unavailable", "Bearer", security.ErrUnavailable, bearerTokenSent, http.StatusServiceUnavailable, challenge(bearerAPI)}, + {"unavailable joined with a verdict", "Bearer", refused(security.ErrTokenNotFound, security.ErrUnavailable), bearerTokenSent, http.StatusServiceUnavailable, challenge(bearerAPI)}, {"expired token", "Bearer", security.ErrTokenExpired, bearerTokenSent, http.StatusUnauthorized, challenge(invalidToken)}, {"token not found", "Bearer", security.ErrTokenNotFound, bearerTokenSent, http.StatusUnauthorized, challenge(invalidToken)}, {"bad signature (invalid credentials)", "Bearer", fmt.Errorf("jwt: invalid signature: %w", security.ErrInvalidCredentials), bearerTokenSent, http.StatusUnauthorized, challenge(invalidToken)}, @@ -74,15 +75,17 @@ func TestDefaultErrorMapperAnswers(t *testing.T) { {"insufficient scope, bearer", "Bearer", security.ErrInsufficientScope, bearerTokenSent, http.StatusForbidden, challenge(insufficient)}, {"access denied without a bearer token", "Bearer", security.ErrAccessDenied, "", http.StatusForbidden, challenge(bearerAPI)}, {"anonymous denial", "Bearer", fmt.Errorf("%w: %w", security.ErrAuthenticationRequired, security.ErrAccessDenied), "", http.StatusUnauthorized, challenge(bearerAPI)}, - {"no extractor", "Bearer", security.ErrNoExtractor, "", http.StatusInternalServerError, nil}, - {"no sentinel", "Bearer", errors.New("boom"), bearerTokenSent, http.StatusInternalServerError, nil}, - {"refusal without a sentinel", "Bearer", refused(errors.New("boom")), bearerTokenSent, http.StatusInternalServerError, nil}, + {"no extractor", "Bearer", security.ErrNoExtractor, "", http.StatusInternalServerError, challenge(bearerAPI)}, + {"no sentinel", "Bearer", errors.New("boom"), bearerTokenSent, http.StatusInternalServerError, challenge(bearerAPI)}, + {"refusal without a sentinel", "Bearer", refused(errors.New("boom")), bearerTokenSent, http.StatusInternalServerError, challenge(bearerAPI)}, {"lowercase scheme keeps the codes", "bearer", security.ErrTokenExpired, bearerTokenSent, http.StatusUnauthorized, challenge(`bearer realm="api", error="invalid_token", error_description="The access token is invalid or has expired."`)}, {"basic, invalid credentials", "Basic", security.ErrInvalidCredentials, "Basic dTpw", http.StatusUnauthorized, challenge(basicAPI)}, {"basic, authentication required", "Basic", security.ErrAuthenticationRequired, "", http.StatusUnauthorized, challenge(basicAPI)}, {"basic, access denied", "Basic", security.ErrAccessDenied, "Basic dTpw", http.StatusForbidden, challenge(basicAPI)}, {"basic, invalid request", "Basic", security.ErrInvalidRequest, "Basic dTpw", http.StatusBadRequest, nil}, + {"basic, unavailable", "Basic", security.ErrUnavailable, "Basic dTpw", http.StatusServiceUnavailable, nil}, + {"basic, no sentinel", "Basic", errors.New("boom"), "Basic dTpw", http.StatusInternalServerError, nil}, {"basic, bearer token sent", "Basic", security.ErrInvalidCredentials, bearerTokenSent, http.StatusUnauthorized, challenge(basicAPI)}, {"custom scheme", "Negotiate", security.ErrInvalidCredentials, bearerTokenSent, http.StatusUnauthorized, challenge(`Negotiate realm="api"`)}, } @@ -107,6 +110,7 @@ func TestDefaultErrorMapperDefaultsSchemeAndRealm(t *testing.T) { }{ {"", "", bearerAPI}, {"Basic", "", basicAPI}, + {"basic", "", `basic realm="api", charset="UTF-8"`}, {"Bearer", "hyperscale", `Bearer realm="hyperscale"`}, {"Bearer", "a \"quoted\" \\ realm\twith tab", `Bearer realm="a \"quoted\" \\ realm` + "\t" + `with tab"`}, } { diff --git a/http/error_mapper.go b/http/error_mapper.go index 0b4528b2..aac15e34 100644 --- a/http/error_mapper.go +++ b/http/error_mapper.go @@ -38,22 +38,25 @@ type ErrorMapper interface { // [security.ErrorClass](err), with challenge scheme S and realm R: // // class status WWW-Authenticate -// invalid_request 400 Bearer + bearer token sent: error="invalid_request" -// unavailable 503 — +// invalid_request 400 Bearer realm=R; + bearer token sent: error="invalid_request" +// unavailable 503 Bearer realm=R // token_expired, token_not_found, // client_secret_mismatch, // invalid_credentials 401 S realm=R; Bearer + bearer token sent: error="invalid_token" // authentication_required, // unsupported_credential 401 S realm=R (no error code, RFC 6750 §3.1) // insufficient_scope, access_denied 403 S realm=R; Bearer + bearer token sent: error="insufficient_scope" -// no_extractor, _OTHER 500 — +// no_extractor, _OTHER 500 Bearer realm=R // -// A 401 always carries a challenge (RFC 9110 §11.6.1); a Basic challenge -// also carries charset="UTF-8" (RFC 7617 §2.1). The RFC 6750 error codes are -// sent only when the request carried a bearer token in its Authorization -// field: invalid_token for every failed check of the token (RFC 9068 §4), -// insufficient_scope for a valid token refused by the access decision. -// An unavailable dependency is not a failed check: 503, no error code. The +// A Bearer resource challenges every refusal, 5xx included: RFC 6750 §3 +// requires the challenge whenever the request carried no access token that +// enables access. Another scheme challenges the 401 — which always carries +// one (RFC 9110 §11.6.1) — and the 403; a Basic challenge also carries +// charset="UTF-8" (RFC 7617 §2.1). The RFC 6750 error codes are sent only +// when the request carried a bearer token in an Authorization field: +// invalid_token for every failed check of the token (RFC 9068 §4), +// insufficient_scope for a valid token refused by the access decision. An +// unavailable dependency is not a failed check: 503, no error code. The // error_description is a fixed, generic string per code — the error chain // is never reflected into the response. // @@ -108,23 +111,17 @@ func (m *defaultErrorMapper) Map(w http.ResponseWriter, r *http.Request, err err http.Error(w, http.StatusText(o.status), o.status) } -// challengeFor returns the WWW-Authenticate value of an outcome, or "". +// challengeFor returns the WWW-Authenticate value of an outcome, or "". A +// Bearer resource challenges every refusal — RFC 6750 §3: the request +// carried no access token enabling access — with the §3.1 error code only +// for a request that carried a bearer token; another scheme challenges the +// 401 (RFC 9110 §11.6.1) and the 403. func (m *defaultErrorMapper) challengeFor(o outcome, r *http.Request) string { - withCode := m.bearer && o.code != "" && r != nil && bearerTokenSent(r.Header.Values("Authorization")) - - switch o.status { - case http.StatusUnauthorized, http.StatusForbidden: - if withCode { - return m.challenge + `, error="` + o.code + `", error_description="` + o.description + `"` - } - + switch { + case m.bearer && o.code != "" && r != nil && bearerTokenSent(r.Header.Values("Authorization")): + return m.challenge + `, error="` + o.code + `", error_description="` + o.description + `"` + case m.bearer, o.status == http.StatusUnauthorized, o.status == http.StatusForbidden: return m.challenge - case http.StatusBadRequest: - if withCode { - return m.challenge + `, error="` + o.code + `", error_description="` + o.description + `"` - } - - return "" default: return "" } diff --git a/http/hardening_test.go b/http/hardening_test.go new file mode 100644 index 00000000..0924a9c7 --- /dev/null +++ b/http/hardening_test.go @@ -0,0 +1,160 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package httpsec_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hyperscale-stack/security" + httpsec "github.com/hyperscale-stack/security/http" + "github.com/hyperscale-stack/security/voter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// derefAuth reads a field in every method: a typed nil — (*derefAuth)(nil), +// the classic `var out *T; return out, nil` — satisfies Authentication but +// panics on its first method call. +type derefAuth struct{ name string } + +func (a *derefAuth) Principal() security.Principal { return fakePrincipal{sub: a.name} } +func (a *derefAuth) Credentials() any { return a.name } +func (a *derefAuth) Authorities() []string { return []string{a.name} } +func (a *derefAuth) IsAuthenticated() bool { return a.name != "" } +func (a *derefAuth) Name() string { return a.name } + +// derefAttr is the Attribute counterpart of derefAuth. +type derefAttr struct{ name string } + +func (a *derefAttr) String() string { return a.name } + +var ( + typedNilAuth security.Authentication = (*derefAuth)(nil) + typedNilAttr security.Attribute = (*derefAttr)(nil) +) + +// scriptedEngine is a custom Engine returning a fixed authentication, with +// or without a context. +type scriptedEngine struct { + auth security.Authentication + nilContext bool +} + +func (e scriptedEngine) Process(ctx context.Context, _ security.Carrier) (context.Context, security.Authentication, error) { + if e.nilContext { + return nil, e.auth, nil + } + + return security.WithAuthentication(ctx, e.auth), e.auth, nil +} + +// Review finding 2 (CORE-I3): a nil engine panicked on the first request. +func TestMiddlewareRefusesANilEngine(t *testing.T) { + t.Parallel() + + assert.PanicsWithValue(t, "httpsec: Middleware: nil Engine", func() { httpsec.Middleware(nil) }) +} + +// Review findings 2 and 3 (mutant R28): a custom Engine returning a typed +// nil, or no context with an authenticated value, is an internal error — +// never a panic, never a pass. +func TestMiddlewareFailsClosedOnAnEngineBreakingItsContract(t *testing.T) { + t.Parallel() + + for name, engine := range map[string]security.Engine{ + "typed nil authentication": scriptedEngine{auth: typedNilAuth}, + "no context, authenticated value": scriptedEngine{auth: newAuth("alice").verified(), nilContext: true}, + "no context, typed nil": scriptedEngine{auth: typedNilAuth, nilContext: true}, + } { + for _, fallback := range []bool{false, true} { + var rec *httptest.ResponseRecorder + + h := httpsec.Middleware(engine, httpsec.WithAnonymousFallback(fallback))(next204()) + + require.NotPanics(t, func() { rec = serve(h, bearerRequest()) }, "%s, fallback=%v", name, fallback) + assert.Equal(t, http.StatusInternalServerError, rec.Code, "%s, fallback=%v", name, fallback) + } + } +} + +// Review finding 2: a nil attribute in a route's static configuration is +// refused at construction instead of denying every request. +func TestAuthorizeRefusesNilAttributes(t *testing.T) { + t.Parallel() + + const want = "httpsec: Authorize: nil Attribute at index 1" + + assert.PanicsWithValue(t, want, func() { httpsec.Authorize(scriptedADM{}, fakeAttr("x"), nil) }) + assert.PanicsWithValue(t, want, func() { httpsec.Authorize(scriptedADM{}, fakeAttr("x"), typedNilAttr) }) + assert.PanicsWithValue(t, want, func() { httpsec.AuthorizeWith(scriptedADM{}, nil, fakeAttr("x"), nil) }) +} + +// Review nit 8: Authorize kept the caller's slice, so reusing it for the +// next route rewrote the first route's policy. +func TestAuthorizeCopiesItsAttributes(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN"), voter.HasRole("USER")) + attrs := []security.Attribute{security.Role("ADMIN")} + + handlers := []http.Handler{ + httpsec.Authorize(adm, attrs...)(next204()), + httpsec.AuthorizeWith(adm, nil, attrs...)(next204()), + } + + attrs[0] = security.Role("USER") + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + req = req.WithContext(security.WithAuthentication(req.Context(), newAuth("alice", "ROLE_USER").verified())) + + for _, h := range handlers { + assert.Equal(t, http.StatusForbidden, serve(h, req).Code) + } +} + +// Review finding 3 (mutant R31): a custom decision manager refusing an +// anonymous caller with ErrInsufficientScope asks it to authenticate. +func TestAuthorizeAsksAnAnonymousCallerToAuthenticateOnInsufficientScope(t *testing.T) { + t.Parallel() + + h := httpsec.Authorize(scriptedADM{err: fmt.Errorf("policy: %w", security.ErrInsufficientScope)}, fakeAttr("x"))(next204()) + + rec := serve(h, httptest.NewRequest(http.MethodGet, "/", nil)) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, []string{bearerAPI}, rec.Header().Values("WWW-Authenticate")) +} + +// Review finding 5 (RFC 6750 §3) and nit 12 (mutant R22): a Bearer resource +// refusing a malformed request challenges it — with the invalid_request +// code when one of the Authorization fields carried a bearer token. +func TestMiddlewareChallengesAMalformedRequest(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(security.NewManager(&scriptedAuthn{name: "x", result: newAuth("alice").verified()}), singleValueExtractor{}) + + for name, c := range map[string]struct { + fields []string + want string + }{ + "two Basic fields": {[]string{"Basic dTpw", "Basic dTpx"}, bearerAPI}, + "Basic then Bearer": {[]string{"Basic dTpw", "Bearer some-token"}, invalidRequest}, + "Bearer then lowercased": {[]string{"Bearer a", "bearer b"}, invalidRequest}, + } { + req := httptest.NewRequest(http.MethodGet, "/", nil) + for _, f := range c.fields { + req.Header.Add("Authorization", f) + } + + rec := serve(httpsec.Middleware(engine)(next204()), req) + + assert.Equal(t, http.StatusBadRequest, rec.Code, name) + assert.Equal(t, []string{c.want}, rec.Header().Values("WWW-Authenticate"), name) + } +} diff --git a/http/mapping_test.go b/http/mapping_test.go index 80c1432d..609d1728 100644 --- a/http/mapping_test.go +++ b/http/mapping_test.go @@ -153,7 +153,7 @@ func TestMiddlewareAnswersOutagesAndBrokenContracts(t *testing.T) { require.NotPanics(t, func() { rec = serve(c.handler, bearerRequest()) }, name) assert.Equal(t, c.status, rec.Code, name) - assert.Empty(t, rec.Header().Values("WWW-Authenticate"), name) + assert.Equal(t, []string{bearerAPI}, rec.Header().Values("WWW-Authenticate"), "RFC 6750 §3: %s", name) } } @@ -291,7 +291,7 @@ func TestMiddlewareEngineWithoutExtractor(t *testing.T) { rec := serve(httpsec.Middleware(engine)(next204()), httptest.NewRequest(http.MethodGet, "/", nil)) assert.Equal(t, http.StatusInternalServerError, rec.Code) - assert.Empty(t, rec.Header().Values("WWW-Authenticate")) + assert.Equal(t, []string{bearerAPI}, rec.Header().Values("WWW-Authenticate")) rec = serve(httpsec.Middleware(engine, httpsec.WithAnonymousFallback(true))(next204()), httptest.NewRequest(http.MethodGet, "/", nil)) assert.Equal(t, http.StatusNoContent, rec.Code) diff --git a/http/middleware.go b/http/middleware.go index 5fc68623..8484312d 100644 --- a/http/middleware.go +++ b/http/middleware.go @@ -27,7 +27,8 @@ const ( ) // errEngineContract reports a custom [security.Engine] that returned no -// context or no authentication without an error: an internal error. +// context or no authentication (nil or a typed nil) without an error: an +// internal error. var errEngineContract = errors.New("httpsec: the engine returned no context or authentication") // Middleware wires a [security.Engine] into the net/http pipeline. @@ -43,9 +44,13 @@ var errEngineContract = errors.New("httpsec: the engine returned no context or a // // The middleware makes its error mapper available to [Authorize], so the // authorization step answers with the same challenge. Middleware panics at -// construction on an invalid challenge configuration (see -// [DefaultErrorMapper]). +// construction on a nil engine and on an invalid challenge configuration +// (see [DefaultErrorMapper]). func Middleware(engine security.Engine, opts ...Option) func(http.Handler) http.Handler { + if engine == nil { + panic("httpsec: Middleware: nil Engine") + } + cfg := buildConfig(opts...) return func(next http.Handler) http.Handler { @@ -68,7 +73,7 @@ func Middleware(engine security.Engine, opts ...Option) func(http.Handler) http. cfg.errorMapper.Map(w, r, err) return - case newCtx == nil || auth == nil: + case newCtx == nil || isNil(auth): cfg.errorMapper.Map(w, r, errEngineContract) return diff --git a/http/nil.go b/http/nil.go new file mode 100644 index 00000000..5c2fd52f --- /dev/null +++ b/http/nil.go @@ -0,0 +1,32 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package httpsec + +import ( + "reflect" + "slices" +) + +// The core, grpcsec and connectrpcsec hold the same unexported helper: the +// dependency policy keeps it out of the core's API. + +// nilableKinds are the kinds whose zero value is a nil a method call can +// dereference. +var nilableKinds = []reflect.Kind{ + reflect.Pointer, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan, reflect.UnsafePointer, +} + +// isNil reports whether v is nil or a typed nil — an interface holding a +// nil pointer, map, slice, function or channel, which satisfies the +// interface but panics on the first method that dereferences it. +func isNil(v any) bool { + if v == nil { + return true + } + + rv := reflect.ValueOf(v) + + return slices.Contains(nilableKinds, rv.Kind()) && rv.IsNil() +} diff --git a/http/nil_internal_test.go b/http/nil_internal_test.go new file mode 100644 index 00000000..3b90bee5 --- /dev/null +++ b/http/nil_internal_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package httpsec + +import ( + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" +) + +func TestIsNil(t *testing.T) { + t.Parallel() + + type ( + ptr *int + fn func() + dict map[string]int + list []int + queue chan int + name string + ) + + x := 1 + + cases := []struct { + name string + v any + want bool + }{ + {"untyped nil", nil, true}, + {"nil pointer", ptr(nil), true}, + {"nil func", fn(nil), true}, + {"nil map", dict(nil), true}, + {"nil slice", list(nil), true}, + {"nil channel", queue(nil), true}, + {"nil unsafe pointer", unsafe.Pointer(nil), true}, + {"pointer", &x, false}, + {"func", fn(func() {}), false}, + {"map", dict{}, false}, + {"slice", list{}, false}, + {"channel", make(queue), false}, + {"string", name(""), false}, + {"struct", struct{}{}, false}, + {"zero int", 0, false}, + } + + for _, c := range cases { + assert.Equal(t, c.want, isNil(c.v), c.name) + } +} From affec1ef9a0c533443efe14d4603fa669bf0e57c Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:18:07 +0200 Subject: [PATCH 16/29] fix(grpc): refuse nil engines and attributes, fail closed on typed nils - UnaryServerInterceptor, StreamServerInterceptor and ServerOptions panic at construction on a nil engine: grpc-go recovers no panic, so it crashed the server on the first RPC. - A custom Engine returning a typed-nil Authentication is answered Internal instead of crashing the server. - UnaryAuthorize, StreamAuthorize and AuthorizeServerOptions copy their attributes and panic at construction on a nil (or typed-nil) one. - Tests pin the anonymous ErrInsufficientScope refusal, the nil-context guard and the options of the stream chains of ServerOptions and AuthorizeServerOptions. Audit: CORE-I3, CORE-05, CORE-10. --- CHANGELOG.md | 9 +- MIGRATION.md | 4 + grpc/authorize.go | 25 ++++-- grpc/contract_test.go | 172 ++++++++++++++++++++++++++++++++++++++ grpc/interceptor.go | 21 ++++- grpc/nil.go | 32 +++++++ grpc/nil_internal_test.go | 53 ++++++++++++ grpc/server_options.go | 8 +- http/nil.go | 4 +- 9 files changed, 309 insertions(+), 19 deletions(-) create mode 100644 grpc/contract_test.go create mode 100644 grpc/nil.go create mode 100644 grpc/nil_internal_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b9e5088..681374ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -691,7 +691,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `PermissionDenied`), a repeated `authorization` value `InvalidArgument`, an `ErrUnavailable` `Unavailable`, and an error wrapping no sentinel `Internal` (was `Unauthenticated`). The authorization interceptors use the - authentication interceptor's error mapper unless given their own. + authentication interceptor's error mapper unless given their own, copy + their attributes and panic on a nil one; the authentication interceptors + and `ServerOptions` panic on a nil engine. - `connectrpcsec.DefaultErrorMapper()` becomes `DefaultErrorMapper(scheme, realm string)` and answers from `security.ErrorClass` like the gRPC mapper (generic messages, @@ -824,8 +826,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `httpsec.Middleware` panic; it is now answered 500, like a nil one. - `grpcsec` sends the metadata staged on its `Carrier` as the response header, refused RPCs included, as its documentation said; it was dropped. - A custom `Engine` returning no authentication no longer panics the - interceptors. + A custom `Engine` returning no authentication — nil or a typed nil — no + longer panics the interceptors, which crashed the server: grpc-go + recovers no panic. - A ConnectRPC unary call refused with `Unauthenticated` (HTTP 401) carried no `WWW-Authenticate` field (RFC 9110 §11.6.1); it now carries the challenge. Headers staged on the `connectrpcsec.Carrier` reach the client diff --git a/MIGRATION.md b/MIGRATION.md index 898f7886..443b2dd7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1487,6 +1487,10 @@ unauthenticated. | Error wrapping no sentinel, `ErrNoExtractor` | `Unauthenticated` | `Internal` | | Metadata staged on the `Carrier` | dropped | sent as the response header | | `UnaryAuthorize` without `WithErrorMapper` | `DefaultErrorMapper()` | the authentication interceptor's mapper | +| A nil engine (`UnaryServerInterceptor(nil)`, `ServerOptions(nil)`, …) | server crash on the first RPC | panic at construction | +| A custom `Engine` returning no context or a typed nil | server crash | `Internal` | +| A nil or typed nil attribute given to `UnaryAuthorize` / `StreamAuthorize` / `AuthorizeServerOptions` | `PermissionDenied` on every RPC | panic at construction | +| Reusing the attribute slice passed to them | rewrote the RPC's policy | no effect (copied) | To migrate: diff --git a/grpc/authorize.go b/grpc/authorize.go index 83c1df94..4343bd47 100644 --- a/grpc/authorize.go +++ b/grpc/authorize.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "slices" "github.com/hyperscale-stack/security" "go.opentelemetry.io/otel" @@ -23,14 +24,15 @@ import ( // On grant the handler runs. On deny it answers with its [WithErrorMapper], // else the mapper of the authentication interceptor before it, else // [DefaultErrorMapper]: codes.PermissionDenied for an authenticated -// principal, codes.Unauthenticated for an unauthenticated one. It panics if -// adm is nil. +// principal, codes.Unauthenticated for an unauthenticated one. It copies +// attrs, and panics if adm or one of attrs is nil (a typed nil included): an +// RPC's policy is static configuration. func UnaryAuthorize( adm security.AccessDecisionManager, attrs []security.Attribute, opts ...Option, ) grpc.UnaryServerInterceptor { - mustDecisionManager(adm) + attrs = mustPolicy(adm, attrs) cfg := buildConfig(opts...) @@ -49,13 +51,13 @@ func UnaryAuthorize( } // StreamAuthorize is the streaming counterpart of [UnaryAuthorize]. It -// panics if adm is nil. +// copies attrs, and panics if adm or one of attrs is nil. func StreamAuthorize( adm security.AccessDecisionManager, attrs []security.Attribute, opts ...Option, ) grpc.StreamServerInterceptor { - mustDecisionManager(adm) + attrs = mustPolicy(adm, attrs) cfg := buildConfig(opts...) @@ -100,8 +102,19 @@ func refusal(auth security.Authentication, err error) error { return err //nolint:wrapcheck // security.* sentinels pass through to the ErrorMapper } -func mustDecisionManager(adm security.AccessDecisionManager) { +// mustPolicy checks the static configuration of the authorization +// interceptors and returns a copy of attrs, so the caller reusing its slice +// cannot rewrite the policy. +func mustPolicy(adm security.AccessDecisionManager, attrs []security.Attribute) []security.Attribute { if adm == nil { panic("grpcsec: nil AccessDecisionManager") } + + for i, a := range attrs { + if isNil(a) { + panic(fmt.Sprintf("grpcsec: nil Attribute at index %d", i)) + } + } + + return slices.Clone(attrs) } diff --git a/grpc/contract_test.go b/grpc/contract_test.go new file mode 100644 index 00000000..266d59b3 --- /dev/null +++ b/grpc/contract_test.go @@ -0,0 +1,172 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package grpcsec_test + +import ( + "context" + "fmt" + "slices" + "testing" + + "github.com/hyperscale-stack/security" + grpcsec "github.com/hyperscale-stack/security/grpc" + "github.com/hyperscale-stack/security/voter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/status" +) + +// derefAuth reads a field in every method: a typed nil — (*derefAuth)(nil), +// the classic `var out *T; return out, nil` — satisfies Authentication but +// panics on its first method call. +type derefAuth struct{ name string } + +func (a *derefAuth) Principal() security.Principal { return fakePrincipal{sub: a.name} } +func (a *derefAuth) Credentials() any { return a.name } +func (a *derefAuth) Authorities() []string { return []string{a.name} } +func (a *derefAuth) IsAuthenticated() bool { return a.name != "" } +func (a *derefAuth) Name() string { return a.name } + +// derefAttr is the Attribute counterpart of derefAuth. +type derefAttr struct{ name string } + +func (a *derefAttr) String() string { return a.name } + +var ( + typedNilAuth security.Authentication = (*derefAuth)(nil) + typedNilAttr security.Attribute = (*derefAttr)(nil) +) + +// scriptedEngine is a custom Engine returning a fixed authentication, with +// or without a context. +type scriptedEngine struct { + auth security.Authentication + nilContext bool +} + +func (e scriptedEngine) Process(ctx context.Context, _ security.Carrier) (context.Context, security.Authentication, error) { + if e.nilContext { + return nil, e.auth, nil + } + + return security.WithAuthentication(ctx, e.auth), e.auth, nil +} + +func watchCode(t *testing.T, c healthpb.HealthClient, ctx context.Context) codes.Code { + t.Helper() + + stream, err := c.Watch(ctx, &healthpb.HealthCheckRequest{}) + require.NoError(t, err) + + _, err = stream.Recv() + + return status.Code(err) +} + +func checkCode(c healthpb.HealthClient, ctx context.Context) codes.Code { + _, err := c.Check(ctx, &healthpb.HealthCheckRequest{}) + + return status.Code(err) +} + +// Review finding 2 (CORE-I3): a nil engine crashed the server on the first +// RPC — grpc-go recovers no panic. +func TestInterceptorsRefuseANilEngine(t *testing.T) { + t.Parallel() + + const want = "grpcsec: nil Engine" + + assert.PanicsWithValue(t, want, func() { grpcsec.UnaryServerInterceptor(nil) }) + assert.PanicsWithValue(t, want, func() { grpcsec.StreamServerInterceptor(nil) }) + assert.PanicsWithValue(t, want, func() { grpcsec.ServerOptions(nil) }) +} + +// Review findings 2 and 3 (mutant R28g): a custom Engine returning a typed +// nil, or no context with an authenticated value, is an internal error. +func TestInterceptorsFailClosedOnAnEngineBreakingItsContract(t *testing.T) { + t.Parallel() + + for name, engine := range map[string]security.Engine{ + "typed nil authentication": scriptedEngine{auth: typedNilAuth}, + "no context, authenticated value": scriptedEngine{auth: newAuth("alice"), nilContext: true}, + } { + for _, fallback := range []bool{false, true} { + client := dialWithOptions(t, grpcsec.ServerOptions(engine, grpcsec.WithAnonymousFallback(fallback))...) + + assert.Equal(t, codes.Internal, checkCode(client, context.Background()), "%s, fallback=%v", name, fallback) + assert.Equal(t, codes.Internal, watchCode(t, client, context.Background()), "%s, fallback=%v", name, fallback) + } + } +} + +// Review finding 2: a nil attribute in the static configuration is refused +// at construction. +func TestAuthorizeRefusesNilAttributes(t *testing.T) { + t.Parallel() + + const want = "grpcsec: nil Attribute at index 1" + + for _, attrs := range [][]security.Attribute{{security.Role("X"), nil}, {security.Role("X"), typedNilAttr}} { + assert.PanicsWithValue(t, want, func() { grpcsec.UnaryAuthorize(scriptedADM{}, attrs) }) + assert.PanicsWithValue(t, want, func() { grpcsec.StreamAuthorize(scriptedADM{}, attrs) }) + assert.PanicsWithValue(t, want, func() { grpcsec.AuthorizeServerOptions(scriptedADM{}, attrs) }) + } +} + +// Review nit 8: the authorization interceptors kept the caller's slice. +func TestAuthorizeCopiesItsAttributes(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN"), voter.HasRole("USER")) + attrs := []security.Attribute{security.Role("ADMIN")} + + client := dialWithOptions(t, slices.Concat( + grpcsec.ServerOptions(newEngine("ROLE_USER")), + grpcsec.AuthorizeServerOptions(adm, attrs), + )...) + + attrs[0] = security.Role("USER") + + assert.Equal(t, codes.PermissionDenied, checkCode(client, bearer(context.Background(), "letmein"))) + assert.Equal(t, codes.PermissionDenied, watchCode(t, client, bearer(context.Background(), "letmein"))) +} + +// Review finding 3 (mutant R35): a custom decision manager refusing an +// anonymous caller with ErrInsufficientScope asks it to authenticate. +func TestAuthorizeAsksAnAnonymousCallerToAuthenticateOnInsufficientScope(t *testing.T) { + t.Parallel() + + adm := scriptedADM{err: fmt.Errorf("policy: %w", security.ErrInsufficientScope)} + client := dialWithOptions(t, grpcsec.AuthorizeServerOptions(adm, nil)...) + + assert.Equal(t, codes.Unauthenticated, checkCode(client, context.Background())) + assert.Equal(t, codes.Unauthenticated, watchCode(t, client, context.Background())) +} + +// Review finding 3 (mutants R37a, R37b): the options reach the stream +// chains of ServerOptions and AuthorizeServerOptions too. +func TestServerOptionsPassTheirOptionsToBothChains(t *testing.T) { + t.Parallel() + + fallback := dialWithOptions(t, grpcsec.ServerOptions(newEngine(), grpcsec.WithAnonymousFallback(true))...) + + assert.Equal(t, codes.OK, checkCode(fallback, context.Background())) + assert.Equal(t, codes.OK, watchCode(t, fallback, context.Background())) + + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) + attrs := []security.Attribute{security.Role("ADMIN")} + + own := dialWithOptions(t, slices.Concat( + grpcsec.ServerOptions(newEngine("ROLE_USER"), grpcsec.WithErrorMapper(fixedMapper{code: codes.Aborted})), + grpcsec.AuthorizeServerOptions(adm, attrs, grpcsec.WithErrorMapper(fixedMapper{code: codes.DataLoss})), + )...) + + assert.Equal(t, codes.Aborted, checkCode(own, context.Background()), "authentication mapper") + assert.Equal(t, codes.Aborted, watchCode(t, own, context.Background()), "authentication mapper") + assert.Equal(t, codes.DataLoss, checkCode(own, bearer(context.Background(), "letmein")), "authorization mapper") + assert.Equal(t, codes.DataLoss, watchCode(t, own, bearer(context.Background(), "letmein")), "authorization mapper") +} diff --git a/grpc/interceptor.go b/grpc/interceptor.go index 94620252..dbe11aa0 100644 --- a/grpc/interceptor.go +++ b/grpc/interceptor.go @@ -18,7 +18,8 @@ import ( const tracerName = "github.com/hyperscale-stack/security/grpc" // errEngineContract reports a custom [security.Engine] that returned no -// context or no authentication without an error: an internal error. +// context or no authentication (nil or a typed nil) without an error: an +// internal error. var errEngineContract = errors.New("grpcsec: the engine returned no context or authentication") // authenticate runs the engine against the RPC metadata and returns the @@ -41,7 +42,7 @@ func authenticate(ctx context.Context, engine security.Engine, cfg *config, meth switch { case err != nil && !tolerated: return ctx, carrier, fmt.Errorf("grpcsec: authenticate: %w", err) - case newCtx == nil || auth == nil: + case newCtx == nil || isNil(auth): return ctx, carrier, errEngineContract case !auth.IsAuthenticated() && !cfg.anonymousFallback: return ctx, carrier, security.ErrAuthenticationRequired @@ -64,8 +65,10 @@ func authenticate(ctx context.Context, engine security.Engine, cfg *config, meth // // It opens a "grpcsec.Authenticate" span but deliberately does NOT open an // "rpc" span — that belongs to otelgrpc, which users compose alongside -// this interceptor. +// this interceptor. It panics if engine is nil. func UnaryServerInterceptor(engine security.Engine, opts ...Option) grpc.UnaryServerInterceptor { + mustEngine(engine) + cfg := buildConfig(opts...) mapper := cfg.ownMapper() @@ -94,8 +97,10 @@ func UnaryServerInterceptor(engine security.Engine, opts ...Option) grpc.UnarySe // StreamServerInterceptor is the streaming counterpart of // [UnaryServerInterceptor]. The wrapped stream exposes the enriched // context through ServerStream.Context(), and the staged metadata is sent -// as the stream's header. +// as the stream's header. It panics if engine is nil. func StreamServerInterceptor(engine security.Engine, opts ...Option) grpc.StreamServerInterceptor { + mustEngine(engine) + cfg := buildConfig(opts...) mapper := cfg.ownMapper() @@ -121,6 +126,14 @@ func StreamServerInterceptor(engine security.Engine, opts ...Option) grpc.Stream } } +// mustEngine refuses a nil engine at construction: grpc-go recovers no +// panic, so it would otherwise crash the server on the first RPC. +func mustEngine(engine security.Engine) { + if engine == nil { + panic("grpcsec: nil Engine") + } +} + // wrappedStream overrides Context() so downstream handlers see the // authenticated context. Every other method delegates to the embedded // grpc.ServerStream. diff --git a/grpc/nil.go b/grpc/nil.go new file mode 100644 index 00000000..149a4674 --- /dev/null +++ b/grpc/nil.go @@ -0,0 +1,32 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package grpcsec + +import ( + "reflect" + "slices" +) + +// The core, httpsec and connectrpcsec hold the same unexported helper: it +// stays out of the core's API. + +// nilableKinds are the kinds whose zero value is a nil a method call can +// dereference. +var nilableKinds = []reflect.Kind{ + reflect.Pointer, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan, reflect.UnsafePointer, +} + +// isNil reports whether v is nil or a typed nil — an interface holding a +// nil pointer, map, slice, function or channel, which satisfies the +// interface but panics on the first method that dereferences it. +func isNil(v any) bool { + if v == nil { + return true + } + + rv := reflect.ValueOf(v) + + return slices.Contains(nilableKinds, rv.Kind()) && rv.IsNil() +} diff --git a/grpc/nil_internal_test.go b/grpc/nil_internal_test.go new file mode 100644 index 00000000..73fd7769 --- /dev/null +++ b/grpc/nil_internal_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package grpcsec + +import ( + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" +) + +func TestIsNil(t *testing.T) { + t.Parallel() + + type ( + ptr *int + fn func() + dict map[string]int + list []int + queue chan int + name string + ) + + x := 1 + + cases := []struct { + name string + v any + want bool + }{ + {"untyped nil", nil, true}, + {"nil pointer", ptr(nil), true}, + {"nil func", fn(nil), true}, + {"nil map", dict(nil), true}, + {"nil slice", list(nil), true}, + {"nil channel", queue(nil), true}, + {"nil unsafe pointer", unsafe.Pointer(nil), true}, + {"pointer", &x, false}, + {"func", fn(func() {}), false}, + {"map", dict{}, false}, + {"slice", list{}, false}, + {"channel", make(queue), false}, + {"string", name(""), false}, + {"struct", struct{}{}, false}, + {"zero int", 0, false}, + } + + for _, c := range cases { + assert.Equal(t, c.want, isNil(c.v), c.name) + } +} diff --git a/grpc/server_options.go b/grpc/server_options.go index 60e898ee..bfbc49a8 100644 --- a/grpc/server_options.go +++ b/grpc/server_options.go @@ -12,7 +12,8 @@ import ( // ServerOptions returns the grpc.ServerOption values that authenticate every // RPC against engine — unary AND streaming. Chaining only // [UnaryServerInterceptor] leaves the streaming RPCs (the health service's -// Watch, server reflection, your own streams) unauthenticated. +// Watch, server reflection, your own streams) unauthenticated. It panics if +// engine is nil. func ServerOptions(engine security.Engine, opts ...Option) []grpc.ServerOption { return []grpc.ServerOption{ grpc.ChainUnaryInterceptor(UnaryServerInterceptor(engine, opts...)), @@ -30,14 +31,13 @@ func ServerOptions(engine security.Engine, opts ...Option) []grpc.ServerOption { // grpcsec.AuthorizeServerOptions(adm, attrs), // )...) // -// It panics if adm is nil. +// It copies attrs, and panics if adm or one of attrs is nil (a typed nil +// included). func AuthorizeServerOptions( adm security.AccessDecisionManager, attrs []security.Attribute, opts ...Option, ) []grpc.ServerOption { - mustDecisionManager(adm) - return []grpc.ServerOption{ grpc.ChainUnaryInterceptor(UnaryAuthorize(adm, attrs, opts...)), grpc.ChainStreamInterceptor(StreamAuthorize(adm, attrs, opts...)), diff --git a/http/nil.go b/http/nil.go index 5c2fd52f..ab866b66 100644 --- a/http/nil.go +++ b/http/nil.go @@ -9,8 +9,8 @@ import ( "slices" ) -// The core, grpcsec and connectrpcsec hold the same unexported helper: the -// dependency policy keeps it out of the core's API. +// The core, grpcsec and connectrpcsec hold the same unexported helper: it +// stays out of the core's API. // nilableKinds are the kinds whose zero value is a nil a method call can // dereference. From 2a2756e4f0b1e11af2230cb269d8b916ba9300a8 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:21:15 +0200 Subject: [PATCH 17/29] fix(connectrpc): challenge every Bearer refusal and refuse nil engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RFC 6750 §3: the default mapper puts the Bearer challenge in the metadata of every refusal of a Bearer resource — InvalidArgument, Unavailable and Internal included — with the RFC 6750 error code only for a request that carried a bearer token. - NewAuthenticationInterceptor panics at construction on a nil engine, and a custom Engine's typed-nil Authentication is answered Internal instead of panicking. - NewAuthorizationInterceptor copies its attributes and panics at construction on a nil (or typed-nil) one. - Tests pin the anonymous ErrInsufficientScope refusal, the nil-context guard, the bearer fact of every Authorization field whatever the case of its scheme, the stream authorization's insufficient_scope and the lowercase Basic charset. Audit: CORE-10, CORE-I3. --- CHANGELOG.md | 11 +- MIGRATION.md | 4 + connectrpc/authorize.go | 14 ++- connectrpc/contract_test.go | 177 ++++++++++++++++++++++++++++++++ connectrpc/error_mapper.go | 22 ++-- connectrpc/error_mapper_test.go | 14 ++- connectrpc/interceptor.go | 13 ++- connectrpc/nil.go | 32 ++++++ connectrpc/nil_internal_test.go | 53 ++++++++++ 9 files changed, 316 insertions(+), 24 deletions(-) create mode 100644 connectrpc/contract_test.go create mode 100644 connectrpc/nil.go create mode 100644 connectrpc/nil_internal_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 681374ad..8fcfbae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -701,9 +701,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree principal refused by the authorization interceptor, `InvalidArgument` for a repeated `Authorization` field, `Unavailable` for `ErrUnavailable`, `Internal` for an error wrapping no sentinel), with the RFC 6750 / RFC - 7617 `WWW-Authenticate` challenge in the error's metadata. The - authorization interceptor uses the authentication interceptor's error - mapper unless given its own. + 7617 `WWW-Authenticate` challenge in the error's metadata — on every + refusal of a Bearer resource. The authorization interceptor uses the + authentication interceptor's error mapper unless given its own, copies + its attributes and panics on a nil one; `NewAuthenticationInterceptor` + panics on a nil engine. - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -833,7 +835,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree no `WWW-Authenticate` field (RFC 9110 §11.6.1); it now carries the challenge. Headers staged on the `connectrpcsec.Carrier` reach the client on a refused call and on a handler error too, and a custom `Engine` - returning no authentication no longer panics the interceptors. + returning no authentication — nil or a typed nil — no longer panics the + interceptors. - Documentation drift: `AttrPrincipalSubject` no longer references a non-existent `SetSubjectAttributeMode`, the HTTP middleware no longer promises a route override it could not offer, and `LIMITATIONS.md` lists diff --git a/MIGRATION.md b/MIGRATION.md index 443b2dd7..a042a8b2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1519,7 +1519,11 @@ takes the challenge configuration and answers like the gRPC one: | Anonymous caller refused by the authorization interceptor | `PermissionDenied` | `Unauthenticated` | | Repeated `Authorization` field | first value used | `InvalidArgument` | | `ErrUnavailable` / error wrapping no sentinel | `Unauthenticated` | `Unavailable` / `Internal` | +| `InvalidArgument`, `Unavailable`, `Internal` of a Bearer resource | no `WWW-Authenticate` | `Bearer realm=R` (RFC 6750 §3; `invalid_request` for a bearer request) | | Headers staged on the `Carrier`, refused call | dropped | in the error metadata | +| `NewAuthenticationInterceptor(nil)` | panic on the first RPC | panic at construction | +| A custom `Engine` returning no context or a typed nil | panic | `Internal` | +| A nil or typed nil attribute given to `NewAuthorizationInterceptor` | `PermissionDenied` on every RPC | panic at construction; the attributes are copied | To migrate: diff --git a/connectrpc/authorize.go b/connectrpc/authorize.go index 8d40937c..f0bbb4a3 100644 --- a/connectrpc/authorize.go +++ b/connectrpc/authorize.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "slices" "connectrpc.com/connect" "github.com/hyperscale-stack/security" @@ -36,8 +37,9 @@ type AuthorizationInterceptor struct { } // NewAuthorizationInterceptor builds a [connect.Interceptor] that enforces adm -// against attrs for every inbound unary and streaming RPC. It panics if adm -// is nil. +// against attrs for every inbound unary and streaming RPC. It copies attrs, +// and panics if adm or one of attrs is nil (a typed nil included): an RPC's +// policy is static configuration. func NewAuthorizationInterceptor( adm security.AccessDecisionManager, attrs []security.Attribute, @@ -47,7 +49,13 @@ func NewAuthorizationInterceptor( panic("connectrpcsec: NewAuthorizationInterceptor: nil AccessDecisionManager") } - return &AuthorizationInterceptor{adm: adm, attrs: attrs, cfg: buildConfig(opts...)} + for i, a := range attrs { + if isNil(a) { + panic(fmt.Sprintf("connectrpcsec: NewAuthorizationInterceptor: nil Attribute at index %d", i)) + } + } + + return &AuthorizationInterceptor{adm: adm, attrs: slices.Clone(attrs), cfg: buildConfig(opts...)} } // Compile-time check. diff --git a/connectrpc/contract_test.go b/connectrpc/contract_test.go new file mode 100644 index 00000000..a38eb704 --- /dev/null +++ b/connectrpc/contract_test.go @@ -0,0 +1,177 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec_test + +import ( + "context" + "fmt" + "testing" + + "connectrpc.com/connect" + "github.com/hyperscale-stack/security" + connectrpcsec "github.com/hyperscale-stack/security/connectrpc" + "github.com/hyperscale-stack/security/voter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// derefAuth reads a field in every method: a typed nil — (*derefAuth)(nil), +// the classic `var out *T; return out, nil` — satisfies Authentication but +// panics on its first method call. +type derefAuth struct{ name string } + +func (a *derefAuth) Principal() security.Principal { return fakePrincipal{sub: a.name} } +func (a *derefAuth) Credentials() any { return a.name } +func (a *derefAuth) Authorities() []string { return []string{a.name} } +func (a *derefAuth) IsAuthenticated() bool { return a.name != "" } +func (a *derefAuth) Name() string { return a.name } + +// derefAttr is the Attribute counterpart of derefAuth. +type derefAttr struct{ name string } + +func (a *derefAttr) String() string { return a.name } + +var ( + typedNilAuth security.Authentication = (*derefAuth)(nil) + typedNilAttr security.Attribute = (*derefAttr)(nil) +) + +// scriptedEngine is a custom Engine returning a fixed authentication, with +// or without a context. +type scriptedEngine struct { + auth security.Authentication + nilContext bool +} + +func (e scriptedEngine) Process(ctx context.Context, _ security.Carrier) (context.Context, security.Authentication, error) { + if e.nilContext { + return nil, e.auth, nil + } + + return security.WithAuthentication(ctx, e.auth), e.auth, nil +} + +// Review finding 2 (CORE-I3): a nil engine panicked on the first RPC. +func TestAuthenticationInterceptorRefusesANilEngine(t *testing.T) { + t.Parallel() + + assert.PanicsWithValue(t, "connectrpcsec: NewAuthenticationInterceptor: nil Engine", + func() { connectrpcsec.NewAuthenticationInterceptor(nil) }) +} + +// Review findings 2 and 3 (mutant R28c): a custom Engine returning a typed +// nil, or no context with an authenticated value, is an internal error. +func TestInterceptorFailsClosedOnAnEngineBreakingItsContract(t *testing.T) { + t.Parallel() + + for name, engine := range map[string]security.Engine{ + "typed nil authentication": scriptedEngine{auth: typedNilAuth}, + "no context, authenticated value": scriptedEngine{auth: newAuth("alice"), nilContext: true}, + } { + for _, fallback := range []bool{false, true} { + interceptor := connectrpcsec.NewAuthenticationInterceptor(engine, connectrpcsec.WithAnonymousFallback(fallback)) + + var err error + + require.NotPanics(t, func() { + _, err = interceptor.WrapUnary((&recordingUnary{}).fn)(context.Background(), unaryReq("letmein")) + }, "%s, fallback=%v", name, fallback) + assert.Equal(t, connect.CodeInternal, connect.CodeOf(err), "%s, fallback=%v", name, fallback) + + require.NotPanics(t, func() { + err = interceptor.WrapStreamingHandler((&recordingStream{}).fn)(context.Background(), newStreamConn(nil)) + }, "%s, fallback=%v", name, fallback) + assert.Equal(t, connect.CodeInternal, connect.CodeOf(err), "%s, fallback=%v", name, fallback) + } + } +} + +// Review finding 2: a nil attribute in the static configuration is refused +// at construction. +func TestAuthorizationRefusesNilAttributes(t *testing.T) { + t.Parallel() + + const want = "connectrpcsec: NewAuthorizationInterceptor: nil Attribute at index 1" + + for _, attrs := range [][]security.Attribute{{security.Role("X"), nil}, {security.Role("X"), typedNilAttr}} { + assert.PanicsWithValue(t, want, func() { connectrpcsec.NewAuthorizationInterceptor(scriptedADM{}, attrs) }) + } +} + +// Review nit 8: the authorization interceptor kept the caller's slice. +func TestAuthorizationCopiesItsAttributes(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN"), voter.HasRole("USER")) + attrs := []security.Attribute{security.Role("ADMIN")} + authz := connectrpcsec.NewAuthorizationInterceptor(adm, attrs) + + attrs[0] = security.Role("USER") + + _, err := chainUnary(connectrpcsec.NewAuthenticationInterceptor(newEngine("ROLE_USER")), authz, (&recordingUnary{}).fn)( + context.Background(), unaryReq("letmein")) + + assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err)) +} + +// Review finding 3 (mutant R39): a custom decision manager refusing an +// anonymous caller with ErrInsufficientScope asks it to authenticate. +func TestAuthorizationAsksAnAnonymousCallerToAuthenticateOnInsufficientScope(t *testing.T) { + t.Parallel() + + authz := connectrpcsec.NewAuthorizationInterceptor(scriptedADM{err: fmt.Errorf("policy: %w", security.ErrInsufficientScope)}, nil) + + _, err := authz.WrapUnary((&recordingUnary{}).fn)(context.Background(), unaryReq("")) + + ce := connectError(t, err) + assert.Equal(t, connect.CodeUnauthenticated, ce.Code()) + assert.Equal(t, []string{bearerAPI}, ce.Meta().Values("WWW-Authenticate")) +} + +// Review finding 5 (RFC 6750 §3) and nit 12 (mutants R54, R55): a Bearer +// resource refusing a malformed request challenges it — with the +// invalid_request code when an Authorization field carried a bearer token, +// whatever its position and the case of its scheme. +func TestWrapUnaryChallengesAMalformedRequest(t *testing.T) { + t.Parallel() + + authn := connectrpcsec.NewAuthenticationInterceptor(newEngine()) + + for name, c := range map[string]struct { + fields []string + want string + }{ + "two Basic fields": {[]string{"Basic dTpw", "Basic dTpx"}, bearerAPI}, + "Basic then Bearer": {[]string{"Basic dTpw", "Bearer letmein"}, invalidRequest}, + "lowercase bearer ×2": {[]string{"bearer a", "bearer b"}, invalidRequest}, + "Bearer then Basic": {[]string{"Bearer letmein", "Basic dTpw"}, invalidRequest}, + "uppercase BEARER ×2": {[]string{"BEARER a", "BEARER b"}, invalidRequest}, + "Digest then lowercase": {[]string{"Digest x", "bearer b"}, invalidRequest}, + } { + _, err := authn.WrapUnary((&recordingUnary{}).fn)(context.Background(), requestWith(c.fields...)) + + ce := connectError(t, err) + assert.Equal(t, connect.CodeInvalidArgument, ce.Code(), name) + assert.Equal(t, []string{c.want}, ce.Meta().Values("WWW-Authenticate"), name) + } +} + +// Review nit 12 (mutant R42): the authorization interceptor's streaming +// refusal of a bearer request carries insufficient_scope. +func TestStreamAuthorizationChallengesABearerRequest(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) + authn := connectrpcsec.NewAuthenticationInterceptor(newEngine("ROLE_USER")) + authz := connectrpcsec.NewAuthorizationInterceptor(adm, []security.Attribute{security.Role("ADMIN")}) + stream := &recordingStream{} + + err := authn.WrapStreamingHandler(authz.WrapStreamingHandler(stream.fn))(context.Background(), newStreamConn(bearerHeader("letmein"))) + + ce := connectError(t, err) + assert.Equal(t, connect.CodePermissionDenied, ce.Code()) + assert.Equal(t, []string{insufficient}, ce.Meta().Values("WWW-Authenticate")) + assert.False(t, stream.called) +} diff --git a/connectrpc/error_mapper.go b/connectrpc/error_mapper.go index 5295a0bb..3a0df3c9 100644 --- a/connectrpc/error_mapper.go +++ b/connectrpc/error_mapper.go @@ -41,18 +41,20 @@ type ErrorMapper interface { // scheme S and realm R in the error's metadata (sent as response headers): // // class code (HTTP) challenge -// invalid_request InvalidArgument (400) Bearer + bearer token sent: error="invalid_request" -// unavailable Unavailable (503) — +// invalid_request InvalidArgument (400) Bearer realm=R; + bearer token sent: error="invalid_request" +// unavailable Unavailable (503) Bearer realm=R // token_expired, token_not_found, // client_secret_mismatch, invalid_credentials Unauthenticated (401) S realm=R; Bearer + bearer token sent: error="invalid_token" // authentication_required, // unsupported_credential Unauthenticated (401) S realm=R (no error code) // insufficient_scope, access_denied PermissionDenied (403) S realm=R; Bearer + bearer token sent: error="insufficient_scope" -// no_extractor, _OTHER Internal (500) — +// no_extractor, _OTHER Internal (500) Bearer realm=R // -// A Basic challenge also carries charset="UTF-8" (RFC 7617 §2.1). Whether -// the request carried a bearer token is told by the interceptors; without -// them no RFC 6750 code is sent. An empty scheme means "Bearer" and an +// A Bearer resource challenges every refusal (RFC 6750 §3); another scheme +// the Unauthenticated and PermissionDenied answers. A Basic challenge also +// carries charset="UTF-8" (RFC 7617 §2.1). Whether the request carried a +// bearer token is told by the interceptors; without them no RFC 6750 code +// is sent. An empty scheme means "Bearer" and an // empty realm [DefaultRealm]. DefaultErrorMapper panics when scheme is not // an RFC 9110 token or realm holds a character a quoted-string cannot // carry: a challenge configuration is static, so it fails at startup. @@ -105,12 +107,16 @@ func (m *defaultErrorMapper) Map(ctx context.Context, err error) error { return ce } -// challengeFor returns the WWW-Authenticate value of an outcome, or "". +// challengeFor returns the WWW-Authenticate value of an outcome, or "". A +// Bearer resource challenges every refusal — RFC 6750 §3: the request +// carried no access token enabling access — with the §3.1 error code only +// for a request that carried a bearer token; another scheme challenges the +// Unauthenticated (HTTP 401) and PermissionDenied (403) answers. func (m *defaultErrorMapper) challengeFor(o outcome, bearerSent bool) string { switch { case m.bearer && o.oauthCode != "" && bearerSent: return m.challenge + `, error="` + o.oauthCode + `", error_description="` + o.description + `"` - case o.challenge: + case m.bearer || o.challenge: return m.challenge default: return "" diff --git a/connectrpc/error_mapper_test.go b/connectrpc/error_mapper_test.go index f9126987..746a1b76 100644 --- a/connectrpc/error_mapper_test.go +++ b/connectrpc/error_mapper_test.go @@ -41,9 +41,9 @@ func TestDefaultErrorMapperClassification(t *testing.T) { msg string challenge []string }{ - {"invalid_request", "", security.ErrInvalidRequest, connect.CodeInvalidArgument, "invalid request", nil}, - {"unavailable", "", security.ErrUnavailable, connect.CodeUnavailable, "authentication unavailable", nil}, - {"unavailable_joined", "", refused(security.ErrTokenNotFound, security.ErrUnavailable), connect.CodeUnavailable, "authentication unavailable", nil}, + {"invalid_request", "", security.ErrInvalidRequest, connect.CodeInvalidArgument, "invalid request", []string{bearerAPI}}, + {"unavailable", "", security.ErrUnavailable, connect.CodeUnavailable, "authentication unavailable", []string{bearerAPI}}, + {"unavailable_joined", "", refused(security.ErrTokenNotFound, security.ErrUnavailable), connect.CodeUnavailable, "authentication unavailable", []string{bearerAPI}}, {"token_expired", "", security.ErrTokenExpired, connect.CodeUnauthenticated, "unauthenticated", []string{bearerAPI}}, {"token_not_found", "", security.ErrTokenNotFound, connect.CodeUnauthenticated, "unauthenticated", []string{bearerAPI}}, {"invalid_credentials", "", security.ErrInvalidCredentials, connect.CodeUnauthenticated, "unauthenticated", []string{bearerAPI}}, @@ -53,10 +53,14 @@ func TestDefaultErrorMapperClassification(t *testing.T) { {"unsupported_credential", "", security.ErrUnsupportedCredential, connect.CodeUnauthenticated, "unauthenticated", []string{bearerAPI}}, {"access_denied", "", security.ErrAccessDenied, connect.CodePermissionDenied, "permission denied", []string{bearerAPI}}, {"insufficient_scope", "", security.ErrInsufficientScope, connect.CodePermissionDenied, "permission denied", []string{bearerAPI}}, - {"no_extractor", "", security.ErrNoExtractor, connect.CodeInternal, "internal error", nil}, - {"no_sentinel", "", errors.New("boom"), connect.CodeInternal, "internal error", nil}, + {"no_extractor", "", security.ErrNoExtractor, connect.CodeInternal, "internal error", []string{bearerAPI}}, + {"no_sentinel", "", errors.New("boom"), connect.CodeInternal, "internal error", []string{bearerAPI}}, {"basic", "Basic", security.ErrInvalidCredentials, connect.CodeUnauthenticated, "unauthenticated", []string{basicAPI}}, {"basic_denied", "Basic", security.ErrAccessDenied, connect.CodePermissionDenied, "permission denied", []string{basicAPI}}, + {"basic_lowercase", "basic", security.ErrAuthenticationRequired, connect.CodeUnauthenticated, "unauthenticated", []string{`basic realm="api", charset="UTF-8"`}}, + {"basic_invalid_request", "Basic", security.ErrInvalidRequest, connect.CodeInvalidArgument, "invalid request", nil}, + {"basic_unavailable", "Basic", security.ErrUnavailable, connect.CodeUnavailable, "authentication unavailable", nil}, + {"basic_no_sentinel", "Basic", errors.New("boom"), connect.CodeInternal, "internal error", nil}, } for _, tc := range tests { diff --git a/connectrpc/interceptor.go b/connectrpc/interceptor.go index 75d03f57..63fd4a3c 100644 --- a/connectrpc/interceptor.go +++ b/connectrpc/interceptor.go @@ -19,7 +19,8 @@ import ( const tracerName = "github.com/hyperscale-stack/security/connectrpc" // errEngineContract reports a custom [security.Engine] that returned no -// context or no authentication without an error: an internal error. +// context or no authentication (nil or a typed nil) without an error: an +// internal error. var errEngineContract = errors.New("connectrpcsec: the engine returned no context or authentication") // flushHeader copies the staged carrier writes into dst, a live response @@ -70,7 +71,7 @@ func authenticate( switch { case err != nil && !tolerated: return ctx, carrier, fmt.Errorf("connectrpcsec: authenticate: %w", err) - case newCtx == nil || auth == nil: + case newCtx == nil || isNil(auth): return ctx, carrier, errEngineContract case !auth.IsAuthenticated() && !cfg.anonymousFallback: return ctx, carrier, security.ErrAuthenticationRequired @@ -101,9 +102,13 @@ type AuthenticationInterceptor struct { // NewAuthenticationInterceptor builds a [connect.Interceptor] that // authenticates every inbound unary and streaming RPC against engine. Install // it with connect.WithInterceptors(...). Client-side calls are passed through -// untouched. It panics on an invalid challenge configuration (see -// [DefaultErrorMapper]). +// untouched. It panics on a nil engine and on an invalid challenge +// configuration (see [DefaultErrorMapper]). func NewAuthenticationInterceptor(engine security.Engine, opts ...Option) *AuthenticationInterceptor { + if engine == nil { + panic("connectrpcsec: NewAuthenticationInterceptor: nil Engine") + } + cfg := buildConfig(opts...) return &AuthenticationInterceptor{engine: engine, cfg: cfg, mapper: cfg.ownMapper()} diff --git a/connectrpc/nil.go b/connectrpc/nil.go new file mode 100644 index 00000000..c481e094 --- /dev/null +++ b/connectrpc/nil.go @@ -0,0 +1,32 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec + +import ( + "reflect" + "slices" +) + +// The core, httpsec and grpcsec hold the same unexported helper: it +// stays out of the core's API. + +// nilableKinds are the kinds whose zero value is a nil a method call can +// dereference. +var nilableKinds = []reflect.Kind{ + reflect.Pointer, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan, reflect.UnsafePointer, +} + +// isNil reports whether v is nil or a typed nil — an interface holding a +// nil pointer, map, slice, function or channel, which satisfies the +// interface but panics on the first method that dereferences it. +func isNil(v any) bool { + if v == nil { + return true + } + + rv := reflect.ValueOf(v) + + return slices.Contains(nilableKinds, rv.Kind()) && rv.IsNil() +} diff --git a/connectrpc/nil_internal_test.go b/connectrpc/nil_internal_test.go new file mode 100644 index 00000000..9bde4ea6 --- /dev/null +++ b/connectrpc/nil_internal_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec + +import ( + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" +) + +func TestIsNil(t *testing.T) { + t.Parallel() + + type ( + ptr *int + fn func() + dict map[string]int + list []int + queue chan int + name string + ) + + x := 1 + + cases := []struct { + name string + v any + want bool + }{ + {"untyped nil", nil, true}, + {"nil pointer", ptr(nil), true}, + {"nil func", fn(nil), true}, + {"nil map", dict(nil), true}, + {"nil slice", list(nil), true}, + {"nil channel", queue(nil), true}, + {"nil unsafe pointer", unsafe.Pointer(nil), true}, + {"pointer", &x, false}, + {"func", fn(func() {}), false}, + {"map", dict{}, false}, + {"slice", list{}, false}, + {"channel", make(queue), false}, + {"string", name(""), false}, + {"struct", struct{}{}, false}, + {"zero int", 0, false}, + } + + for _, c := range cases { + assert.Equal(t, c.want, isNil(c.v), c.name) + } +} From 86e24c3677cc97130ea1e50de1a652f7808fbe46 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:24:09 +0200 Subject: [PATCH 18/29] docs(bearer): state the error and authority rules of a TokenVerifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A verifier refusing a token wraps a credential sentinel (401 invalid_token), one that could not check it — key set or introspection endpoint down — wraps security.ErrUnavailable (503 / Unavailable), and an error wrapping no sentinel is an internal error. Authorities name roles ROLE_ and scopes scope:, what the voters match. A test pins the classes the authenticator keeps. Audit: CORE-I6, CORE-I1. --- bearer/error_class_test.go | 41 ++++++++++++++++++++++++++++++++++++++ bearer/verifier.go | 16 ++++++++++++--- 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 bearer/error_class_test.go diff --git a/bearer/error_class_test.go b/bearer/error_class_test.go new file mode 100644 index 00000000..8e3d07c2 --- /dev/null +++ b/bearer/error_class_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package bearer_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/bearer" + "github.com/stretchr/testify/assert" +) + +// The TokenVerifier contract: the verifier's sentinel decides the answer — +// a refused token, an outage, or an internal error without a sentinel. +func TestAuthenticatorKeepsTheVerifiersErrorClass(t *testing.T) { + t.Parallel() + + for name, c := range map[string]struct { + err error + want string + }{ + "expired": {fmt.Errorf("jwt: %w", security.ErrTokenExpired), security.ErrorClassTokenExpired}, + "unknown": {fmt.Errorf("introspection: %w", security.ErrTokenNotFound), security.ErrorClassTokenNotFound}, + "bad token": {fmt.Errorf("jwt: %w", security.ErrInvalidCredentials), security.ErrorClassInvalidCredentials}, + "key set out": {fmt.Errorf("jwks: %w", security.ErrUnavailable), security.ErrorClassUnavailable}, + "no sentinel": {errors.New("verifier bug"), security.ErrorClassOther}, + } { + a := bearer.NewAuthenticator(bearer.VerifierFunc(func(context.Context, string) (security.Authentication, error) { + return nil, c.err + })) + + _, err := a.Authenticate(context.Background(), bearer.New("tk")) + + assert.Equal(t, c.want, security.ErrorClass(err), name) + } +} diff --git a/bearer/verifier.go b/bearer/verifier.go index e03ca7ca..c523a354 100644 --- a/bearer/verifier.go +++ b/bearer/verifier.go @@ -18,9 +18,19 @@ import ( // - introspection-backed verifiers calling RFC 7662 endpoints // - custom verifiers calling an internal auth service // -// Errors MUST wrap one of [security.ErrTokenExpired], [security.ErrTokenNotFound] -// or [security.ErrInvalidCredentials] so the default HTTP / gRPC error mappers -// translate them to the right status / code. +// On success Verify returns an authenticated value whose authorities name +// roles as [security.RolePrefix] + role ("ROLE_ADMIN") and scopes as one +// [security.ScopePrefix] + scope authority each ("scope:read") — what +// voter.HasRole and voter.HasScope match. On failure its error MUST wrap a +// security sentinel, which tells the transports how to answer: +// +// - a refused token wraps [security.ErrTokenExpired], +// [security.ErrTokenNotFound] or [security.ErrInvalidCredentials] +// (401, invalid_token); +// - a token that could not be checked — key set, introspection endpoint +// or auth service temporarily down — wraps [security.ErrUnavailable] +// (503 / Unavailable, retryable), never a credential sentinel; +// - an error wrapping no sentinel is an internal error (500 / Internal). type TokenVerifier interface { Verify(ctx context.Context, token string) (security.Authentication, error) } From edd8acf6c41f27f515fb366610212b12b1aa2966 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:24:09 +0200 Subject: [PATCH 19/29] docs(basic): state the error and authority rules of a UserLoader A loader reports an unknown user with security.ErrInvalidCredentials and a user store that is down with security.ErrUnavailable: the outage is answered 503 / Unavailable for every username, any other loader error is a refusal. The AuthorityResolver names roles ROLE_ and scopes scope:. A test pins the classes. Audit: CORE-I6, CORE-I1. --- basic/authenticator.go | 10 ++++++++-- basic/error_class_test.go | 37 +++++++++++++++++++++++++++++++++++++ basic/loader.go | 6 +++++- 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 basic/error_class_test.go diff --git a/basic/authenticator.go b/basic/authenticator.go index 25b984eb..61ddfb1e 100644 --- a/basic/authenticator.go +++ b/basic/authenticator.go @@ -15,7 +15,10 @@ import ( // AuthorityResolver maps a [PasswordUser] to the authorities (roles, scopes, // claims) attached to the resulting [security.Authentication]. The default // resolver returns nil; applications that ship role-based authorization -// provide one that reads the authorities from the user record. +// provide one that reads the authorities from the user record. Name roles +// [security.RolePrefix] + role ("ROLE_ADMIN") and scopes one +// [security.ScopePrefix] + scope authority each ("scope:read"): that is +// what voter.HasRole and voter.HasScope match. type AuthorityResolver func(PasswordUser) []string // Authenticator implements [security.Authenticator] for the HTTP Basic @@ -24,7 +27,10 @@ type AuthorityResolver func(PasswordUser) []string // authenticated [Authentication]. // // Errors are always wrapped in [security.ErrInvalidCredentials] to avoid -// account-enumeration via response-time / response-code analysis. Detailed +// account-enumeration via response-time / response-code analysis — but a +// [UserLoader] error wrapping [security.ErrUnavailable] keeps it, and +// [security.ErrorClass] ranks the outage first: a user store that is down +// is answered 503 / Unavailable for every username, not refused. Detailed // causes remain reachable through errors.As / errors.Is for server-side // telemetry only — do NOT mirror them in the client response. type Authenticator struct { diff --git a/basic/error_class_test.go b/basic/error_class_test.go new file mode 100644 index 00000000..830b6f9f --- /dev/null +++ b/basic/error_class_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package basic_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/basic" + "github.com/stretchr/testify/assert" +) + +// The UserLoader contract: an unavailable store is an outage (503), any +// other loader error a refusal (401). +func TestAuthenticatorClassifiesLoaderErrors(t *testing.T) { + t.Parallel() + + for name, c := range map[string]struct { + err error + want string + }{ + "unavailable store": {fmt.Errorf("db: %w", security.ErrUnavailable), security.ErrorClassUnavailable}, + "unknown user": {fmt.Errorf("lookup: %w", security.ErrInvalidCredentials), security.ErrorClassInvalidCredentials}, + "plain error": {errors.New("db: boom"), security.ErrorClassInvalidCredentials}, + } { + a := basic.NewAuthenticator(&stubLoader{err: c.err}, newHasher(t)) + + _, err := a.Authenticate(context.Background(), basic.New("alice", "p4ss")) + + assert.Equal(t, c.want, security.ErrorClass(err), name) + } +} diff --git a/basic/loader.go b/basic/loader.go index 887c65ff..82237824 100644 --- a/basic/loader.go +++ b/basic/loader.go @@ -45,7 +45,11 @@ type PasswordUser interface { // // On unknown user, implementations SHOULD return an error wrapping // [security.ErrInvalidCredentials] to prevent account enumeration via -// response-time / response-code differences. +// response-time / response-code differences. A store that is temporarily +// unavailable SHOULD be reported with an error wrapping +// [security.ErrUnavailable]: the [Authenticator] then answers 503 / +// Unavailable (retryable) instead of refusing the credentials. Any other +// error is a refusal ([security.ErrInvalidCredentials], 401). type UserLoader interface { LoadByUsername(ctx context.Context, username string) (PasswordUser, error) } From 949102dde9a28ea9f66e31e0bc26f884b430a4e7 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:24:09 +0200 Subject: [PATCH 20/29] docs(session): state the error and authority rules of a PrincipalLoader A loader refusing a session wraps security.ErrInvalidCredentials (401), one whose store is down security.ErrUnavailable (503 / Unavailable); an error wrapping no sentinel is an internal error (500). Authorities name roles ROLE_ and scopes scope:. A test pins the classes. Audit: CORE-I6, CORE-I1. --- session/authenticator.go | 12 +++++++++-- session/error_class_test.go | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 session/error_class_test.go diff --git a/session/authenticator.go b/session/authenticator.go index c461f182..d592071c 100644 --- a/session/authenticator.go +++ b/session/authenticator.go @@ -25,8 +25,16 @@ import ( // } type PrincipalLoader interface { // Load resolves the principal and its authorities from the session - // values. Returning an error fails authentication; the error SHOULD - // wrap security.ErrInvalidCredentials so the error mappers route it. + // values. Name roles security.RolePrefix + role ("ROLE_ADMIN") and + // scopes one security.ScopePrefix + scope authority each + // ("scope:read"): that is what voter.HasRole and voter.HasScope match. + // + // Returning an error fails authentication, and its sentinel tells the + // transports how to answer: wrap security.ErrInvalidCredentials for a + // session that must be refused (unknown or disabled user: 401), + // security.ErrUnavailable for a user store that is temporarily down + // (503 / Unavailable, retryable); an error wrapping no sentinel is an + // internal error (500 / Internal). Load(ctx context.Context, values map[string]any) (security.Principal, []string, error) } diff --git a/session/error_class_test.go b/session/error_class_test.go new file mode 100644 index 00000000..2d903701 --- /dev/null +++ b/session/error_class_test.go @@ -0,0 +1,40 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The PrincipalLoader contract: a refused session, an unavailable store, or +// an internal error without a sentinel. +func TestSessionAuthenticatorKeepsTheLoadersErrorClass(t *testing.T) { + t.Parallel() + + mgr := newManager(t) + loginCarrier := newCarrier() + _, err := mgr.Login(context.Background(), loginCarrier, principal{sub: "alice"}) + require.NoError(t, err) + + for name, c := range map[string]struct { + err error + want string + }{ + "refused": {fmt.Errorf("user disabled: %w", security.ErrInvalidCredentials), security.ErrorClassInvalidCredentials}, + "store down": {fmt.Errorf("db: %w", security.ErrUnavailable), security.ErrorClassUnavailable}, + "no sentinel": {errors.New("loader bug"), security.ErrorClassOther}, + } { + _, _, err := engineFor(mgr, stubLoader{err: c.err}).Process(context.Background(), loginCarrier.replay()) + + assert.Equal(t, c.want, security.ErrorClass(err), name) + } +} From ec29c07d2b226c2858cf33f8ba486a132006ce47 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:37:21 +0200 Subject: [PATCH 21/29] test(connectrpc): pin the bare challenge of uncoded bearer refusals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bearer request answered Unauthenticated without a token, Unavailable or Internal carries the bare Bearer challenge: RFC 6750 §3.1 defines no code for these answers, and an empty error="" would break the challenge grammar. Audit: CORE-10. --- connectrpc/contract_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/connectrpc/contract_test.go b/connectrpc/contract_test.go index a38eb704..095c3900 100644 --- a/connectrpc/contract_test.go +++ b/connectrpc/contract_test.go @@ -6,6 +6,7 @@ package connectrpcsec_test import ( "context" + "errors" "fmt" "testing" @@ -175,3 +176,33 @@ func TestStreamAuthorizationChallengesABearerRequest(t *testing.T) { assert.Equal(t, []string{insufficient}, ce.Meta().Values("WWW-Authenticate")) assert.False(t, stream.called) } + +// Re-review nit (mutant N6): a bearer request answered with a class that +// has no RFC 6750 code — authentication required, an outage, an internal +// error — gets the bare challenge, never an empty error="". +func TestWrapUnaryChallengesABearerRequestWithoutACode(t *testing.T) { + t.Parallel() + + failing := func(err error) security.Engine { + return security.NewEngine(security.NewManager(security.AuthenticatorFunc( + func(context.Context, security.Authentication) (security.Authentication, error) { return nil, err })), + tokenExtractor{}) + } + + for name, c := range map[string]struct { + engine security.Engine + authorization string + code connect.Code + }{ + "bearer scheme without a token": {newEngine(), "Bearer", connect.CodeUnauthenticated}, + "outage": {failing(fmt.Errorf("jwks: %w", security.ErrUnavailable)), "Bearer letmein", connect.CodeUnavailable}, + "internal error": {failing(errors.New("verifier bug")), "Bearer letmein", connect.CodeInternal}, + } { + _, err := connectrpcsec.NewAuthenticationInterceptor(c.engine).WrapUnary((&recordingUnary{}).fn)( + context.Background(), requestWith(c.authorization)) + + ce := connectError(t, err) + assert.Equal(t, c.code, ce.Code(), name) + assert.Equal(t, []string{bearerAPI}, ce.Meta().Values("WWW-Authenticate"), name) + } +} From 4273df095033394f0922fe01f5a54c06618ace59 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:37:21 +0200 Subject: [PATCH 22/29] docs: correct the nil-attribute rows of the transport migration tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change a nil attribute made Decide panic on every request — crashing a gRPC server — rather than being answered 403 / PermissionDenied. Audit: CORE-I3. --- MIGRATION.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index a042a8b2..a0108f37 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1449,7 +1449,7 @@ and `401` to an infrastructure failure. The mapping now comes from | Error wrapping no sentinel, `ErrNoExtractor`, a custom `Engine` returning no context or a typed nil | `401` / panic | `500` (with a bare challenge for a Bearer resource) | | Realm with a control or non-ASCII character | Go-escaped | `Middleware` / `DefaultErrorMapper` panic | | `Middleware(nil)` | panic on the first request | panic at construction | -| `Authorize(adm, …, nil)` (a nil or typed nil attribute) | `403` on every request | panic at construction | +| `Authorize(adm, …, nil)` (a nil or typed nil attribute) | panic on every request | panic at construction | | Reusing the attribute slice passed to `Authorize` | rewrote the route's policy | no effect (copied) | To migrate: @@ -1489,7 +1489,7 @@ unauthenticated. | `UnaryAuthorize` without `WithErrorMapper` | `DefaultErrorMapper()` | the authentication interceptor's mapper | | A nil engine (`UnaryServerInterceptor(nil)`, `ServerOptions(nil)`, …) | server crash on the first RPC | panic at construction | | A custom `Engine` returning no context or a typed nil | server crash | `Internal` | -| A nil or typed nil attribute given to `UnaryAuthorize` / `StreamAuthorize` / `AuthorizeServerOptions` | `PermissionDenied` on every RPC | panic at construction | +| A nil or typed nil attribute given to `UnaryAuthorize` / `StreamAuthorize` / `AuthorizeServerOptions` | panic on every RPC: the server crashes | panic at construction | | Reusing the attribute slice passed to them | rewrote the RPC's policy | no effect (copied) | To migrate: @@ -1523,7 +1523,7 @@ takes the challenge configuration and answers like the gRPC one: | Headers staged on the `Carrier`, refused call | dropped | in the error metadata | | `NewAuthenticationInterceptor(nil)` | panic on the first RPC | panic at construction | | A custom `Engine` returning no context or a typed nil | panic | `Internal` | -| A nil or typed nil attribute given to `NewAuthorizationInterceptor` | `PermissionDenied` on every RPC | panic at construction; the attributes are copied | +| A nil or typed nil attribute given to `NewAuthorizationInterceptor` | panic on every RPC | panic at construction; the attributes are copied | To migrate: From 327e753ce8d083a9ec06408e73347a134a9bd85a Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 03:24:58 +0200 Subject: [PATCH 23/29] test(core): build the doubles of the new tests from the generated mocks The contract, decision and span tests added by this change use the mockery mocks (make generate) instead of hand-written voters, managers, extractors and authenticators: the typed nils are nil mock pointers, a double that must never be consulted has no expectation, and the voters expect exactly the attributes they support. Authentication and Attribute values and the header-backed carrier stay plain values. Audit: CORE-01, CORE-I3, CORE-07. --- access_decision_manager_supported_test.go | 52 +++----- contract_test.go | 155 +++++++--------------- doubles_test.go | 103 ++++++++++++++ errorclass_test.go | 20 ++- 4 files changed, 185 insertions(+), 145 deletions(-) create mode 100644 doubles_test.go diff --git a/access_decision_manager_supported_test.go b/access_decision_manager_supported_test.go index 0ddee5e6..29fe9502 100644 --- a/access_decision_manager_supported_test.go +++ b/access_decision_manager_supported_test.go @@ -10,25 +10,10 @@ import ( "github.com/hyperscale-stack/security" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -// recordingVoter supports the attributes with the given prefix and records -// the attributes it is asked to vote on. -type recordingVoter struct { - scriptedVoter - - seen []string -} - -func (r *recordingVoter) Vote(ctx context.Context, auth security.Authentication, attrs []security.Attribute) security.Decision { - for _, a := range attrs { - r.seen = append(r.seen, a.String()) - } - - return r.scriptedVoter.Vote(ctx, auth, attrs) -} - // User decision U2: an attribute that no voter supports denies the // decision, whatever the strategy or the abstain fallback — it used to be // ignored, which widened access under unanimous and consensus. @@ -37,12 +22,12 @@ func TestDecideDeniesWhenAnAttributeIsSupportedByNoVoter(t *testing.T) { attrs := []security.Attribute{stringAttr("scope:read"), stringAttr("role:ADMIN"), stringAttr("role:USER")} for name, adm := range map[string]security.AccessDecisionManager{ - "affirmative": security.NewAffirmativeDecisionManager(&scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}), + "affirmative": security.NewAffirmativeDecisionManager(prefixVoter(t, "scope:", security.DecisionGrant)), "consensus": security.NewConsensusDecisionManager([]security.Voter{ - &scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}, + prefixVoter(t, "scope:", security.DecisionGrant), }, security.WithTieBreak(security.DecisionGrant)), "unanimous": security.NewUnanimousDecisionManager([]security.Voter{ - &scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}, + prefixVoter(t, "scope:", security.DecisionGrant), }, security.WithAbstainFallback(security.DecisionGrant)), } { spans := spanRecorder(func() { @@ -56,7 +41,7 @@ func TestDecideDeniesWhenAnAttributeIsSupportedByNoVoter(t *testing.T) { } func TestDecideSupportedAttributesRecordNoUnsupportedCount(t *testing.T) { - adm := security.NewAffirmativeDecisionManager(&scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}) + adm := security.NewAffirmativeDecisionManager(prefixVoter(t, "scope:", security.DecisionGrant)) spans := spanRecorder(func() { require.NoError(t, adm.Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), @@ -72,16 +57,23 @@ func TestDecideSupportedAttributesRecordNoUnsupportedCount(t *testing.T) { func TestDecidePassesEachVoterOnlyTheAttributesItSupports(t *testing.T) { t.Parallel() - scopes := &recordingVoter{scriptedVoter: scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}} - roles := &recordingVoter{scriptedVoter: scriptedVoter{prefix: "role:", vote: security.DecisionGrant}} + // Each mock accepts one Vote, with exactly the attributes it supports. + scopes := security.NewMockVoter(t) + scopes.EXPECT().Supports(mock.Anything).RunAndReturn(hasPrefix("scope:")) + scopes.EXPECT().Vote(mock.Anything, mock.Anything, []security.Attribute{stringAttr("scope:read"), stringAttr("scope:write")}). + Return(security.DecisionGrant).Once() + + roles := security.NewMockVoter(t) + roles.EXPECT().Supports(mock.Anything).RunAndReturn(hasPrefix("role:")) + roles.EXPECT().Vote(mock.Anything, mock.Anything, []security.Attribute{stringAttr("role:ADMIN")}). + Return(security.DecisionGrant).Once() + adm := security.NewUnanimousDecisionManager([]security.Voter{scopes, roles}) err := adm.Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), []security.Attribute{stringAttr("scope:read"), stringAttr("role:ADMIN"), stringAttr("scope:write")}) require.NoError(t, err) - assert.Equal(t, []string{"scope:read", "scope:write"}, scopes.seen) - assert.Equal(t, []string{"role:ADMIN"}, roles.seen) } // The audit's positive baseline, pinned: every strategy denies when there is @@ -90,7 +82,7 @@ func TestEveryStrategyDeniesByDefault(t *testing.T) { t.Parallel() alice := newFakeAuth("alice").withAuthenticated() - abstain := &scriptedVoter{prefix: "scope:", vote: security.DecisionAbstain} + abstain := prefixVoter(t, "scope:", security.DecisionAbstain) for name, adm := range map[string]security.AccessDecisionManager{ "affirmative": security.NewAffirmativeDecisionManager(abstain), @@ -114,8 +106,8 @@ func TestEveryStrategyDeniesByDefault(t *testing.T) { func TestDecideDeniesWithoutAttributesOrVotersWhateverTheFallback(t *testing.T) { t.Parallel() - abstain := &scriptedVoter{prefix: "scope:", vote: security.DecisionAbstain} - grant := &scriptedVoter{prefix: "scope:", vote: security.DecisionGrant} + abstain := prefixVoter(t, "scope:", security.DecisionAbstain) + grant := prefixVoter(t, "scope:", security.DecisionGrant) for name, adm := range map[string]security.AccessDecisionManager{ "unanimous, fallback grant": security.NewUnanimousDecisionManager([]security.Voter{abstain}, @@ -145,9 +137,9 @@ func TestConsensusDeniesOnAMajorityOfDenies(t *testing.T) { t.Parallel() adm := security.NewConsensusDecisionManager([]security.Voter{ - &scriptedVoter{prefix: "scope:", vote: security.DecisionDeny}, - &scriptedVoter{prefix: "scope:", vote: security.DecisionDeny}, - &scriptedVoter{prefix: "scope:", vote: security.DecisionGrant}, + prefixVoter(t, "scope:", security.DecisionDeny), + prefixVoter(t, "scope:", security.DecisionDeny), + prefixVoter(t, "scope:", security.DecisionGrant), }, security.WithTieBreak(security.DecisionGrant)) err := adm.Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), []security.Attribute{stringAttr("scope:read")}) diff --git a/contract_test.go b/contract_test.go index 1f915cd9..a2512a52 100644 --- a/contract_test.go +++ b/contract_test.go @@ -11,64 +11,10 @@ import ( "github.com/hyperscale-stack/security" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -// authReadingVoter dereferences the Authentication it is given: it proves -// that Decide never hands a nil Authentication to a voter. -type authReadingVoter struct{} - -func (authReadingVoter) Supports(security.Attribute) bool { return true } - -func (authReadingVoter) Vote(_ context.Context, auth security.Authentication, _ []security.Attribute) security.Decision { - if auth.IsAuthenticated() { - return security.DecisionGrant - } - - return security.DecisionDeny -} - -// constDecisionVoter supports everything and returns a fixed, possibly -// undefined, Decision. -type constDecisionVoter struct{ d security.Decision } - -func (constDecisionVoter) Supports(security.Attribute) bool { return true } - -func (v constDecisionVoter) Vote(context.Context, security.Authentication, []security.Attribute) security.Decision { - return v.d -} - -// derefAuth reads a field in every method: a typed nil — (*derefAuth)(nil), -// the classic `var out *T; return out, nil` — satisfies Authentication but -// panics on its first method call. -type derefAuth struct{ name string } - -func (a *derefAuth) Principal() security.Principal { return fakePrincipal{subject: a.name} } -func (a *derefAuth) Credentials() any { return a.name } -func (a *derefAuth) Authorities() []string { return []string{a.name} } -func (a *derefAuth) IsAuthenticated() bool { return a.name != "" } -func (a *derefAuth) Name() string { return a.name } - -// derefAttr is the Attribute counterpart of derefAuth. -type derefAttr struct{ name string } - -func (a *derefAttr) String() string { return a.name } - -var ( - typedNilAuth security.Authentication = (*derefAuth)(nil) - typedNilAttr security.Attribute = (*derefAttr)(nil) -) - -// scriptedManager is a custom Manager returning a fixed result. -type scriptedManager struct { - result security.Authentication - err error -} - -func (m scriptedManager) Authenticate(context.Context, security.Authentication) (security.Authentication, error) { - return m.result, m.err -} - // credentialSentinels are the sentinels that state a verdict on the // credentials; a contract violation must wrap none of them. var credentialSentinels = []error{ @@ -138,11 +84,8 @@ func TestEngineFailsClosedWhenAnAuthenticatorReturnsNoAuthenticatedValue(t *test t.Parallel() engine := security.NewEngine( - security.NewManager(security.AuthenticatorFunc( - func(context.Context, security.Authentication) (security.Authentication, error) { - return result, nil - })), - scriptedExtractor{auth: newFakeAuth("alice").withCredentials("pw")}, + security.NewManager(authenticatorReturning(t, result, nil)), + extractorReturning(t, newFakeAuth("alice").withCredentials("pw"), nil), ) var ( @@ -181,8 +124,8 @@ func TestEngineFailsClosedWhenTheManagerReturnsNoAuthenticatedValue(t *testing.T t.Parallel() engine := security.NewEngine( - scriptedManager{result: result}, - scriptedExtractor{auth: newFakeAuth("alice").withCredentials("pw")}, + managerReturning(t, result, nil), + extractorReturning(t, newFakeAuth("alice").withCredentials("pw"), nil), ) var ( @@ -211,11 +154,17 @@ func TestEngineFailsClosedWhenTheManagerReturnsNoAuthenticatedValue(t *testing.T func TestEngineFailureLeavesNoCredentialsInContext(t *testing.T) { t.Parallel() + // The refusing authenticator hands its input — the credentials — back + // with its error. + refusing := security.NewMockAuthenticator(t) + refusing.EXPECT().Supports(mock.Anything).Return(true) + refusing.EXPECT().Authenticate(mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, a security.Authentication) (security.Authentication, error) { + return a, fmt.Errorf("load user: %w", security.ErrInvalidCredentials) + }) + cases := map[string]security.Manager{ - "refused": security.NewManager(security.AuthenticatorFunc( - func(_ context.Context, a security.Authentication) (security.Authentication, error) { - return a, fmt.Errorf("load user: %w", security.ErrInvalidCredentials) - })), + "refused": security.NewManager(refusing), "unsupported": security.NewManager(), } @@ -223,7 +172,7 @@ func TestEngineFailureLeavesNoCredentialsInContext(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - engine := security.NewEngine(manager, scriptedExtractor{auth: newFakeAuth("alice").withCredentials("S3cr3t!")}) + engine := security.NewEngine(manager, extractorReturning(t, newFakeAuth("alice").withCredentials("S3cr3t!"), nil)) ctx, got, err := engine.Process(context.Background(), newMapCarrier()) @@ -242,7 +191,7 @@ func TestEngineFailureLeavesNoCredentialsInContext(t *testing.T) { func TestManagerFailureReturnsAnonymous(t *testing.T) { t.Parallel() - refusing := &scriptedAuthenticator{name: "x", err: security.ErrInvalidCredentials} + refusing := authenticatorReturning(t, nil, security.ErrInvalidCredentials) for name, m := range map[string]security.Manager{ "no authenticator": security.NewManager(), @@ -261,14 +210,16 @@ func TestManagerFailureReturnsAnonymous(t *testing.T) { func TestManagerTreatsARuntimeBailOutAsAnAbstention(t *testing.T) { t.Parallel() - bailOut := func() *scriptedAuthenticator { - return &scriptedAuthenticator{name: "bail", err: fmt.Errorf("out of scope: %w", security.ErrUnsupportedCredential)} + bailOut := func(t *testing.T) *security.MockAuthenticator { + t.Helper() + + return authenticatorReturning(t, nil, fmt.Errorf("out of scope: %w", security.ErrUnsupportedCredential)) } t.Run("bail-out then refusal", func(t *testing.T) { t.Parallel() - m := security.NewManager(bailOut(), &scriptedAuthenticator{name: "basic", err: security.ErrInvalidCredentials}) + m := security.NewManager(bailOut(t), authenticatorReturning(t, nil, security.ErrInvalidCredentials)) _, err := m.Authenticate(context.Background(), newFakeAuth("alice")) @@ -280,7 +231,7 @@ func TestManagerTreatsARuntimeBailOutAsAnAbstention(t *testing.T) { t.Run("bail-outs only", func(t *testing.T) { t.Parallel() - m := security.NewManager(bailOut(), bailOut()) + m := security.NewManager(bailOut(t), bailOut(t)) _, err := m.Authenticate(context.Background(), newFakeAuth("alice")) @@ -294,10 +245,7 @@ func TestManagerTreatsARuntimeBailOutAsAnAbstention(t *testing.T) { t.Parallel() for _, verdict := range []error{security.ErrInvalidRequest, security.ErrUnavailable, security.ErrInvalidCredentials} { - m := security.NewManager(&scriptedAuthenticator{ - name: "joined", - err: fmt.Errorf("%w: %w", security.ErrUnsupportedCredential, verdict), - }) + m := security.NewManager(authenticatorReturning(t, nil, fmt.Errorf("%w: %w", security.ErrUnsupportedCredential, verdict))) _, err := m.Authenticate(context.Background(), newFakeAuth("alice")) @@ -310,7 +258,7 @@ func TestManagerTreatsARuntimeBailOutAsAnAbstention(t *testing.T) { t.Parallel() winner := newFakeAuth("alice").withAuthenticated() - m := security.NewManager(bailOut(), &scriptedAuthenticator{name: "ok", result: winner}) + m := security.NewManager(bailOut(t), authenticatorReturning(t, winner, nil)) got, err := m.Authenticate(context.Background(), newFakeAuth("alice")) @@ -330,7 +278,7 @@ func TestManagerTreatsAMissingOrUnauthenticatedResultAsARefusal(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - violator := &scriptedAuthenticator{name: "violator", result: result} + violator := authenticatorReturning(t, result, nil) var err error @@ -343,8 +291,7 @@ func TestManagerTreatsAMissingOrUnauthenticatedResultAsARefusal(t *testing.T) { assertNoCredentialSentinel(t, err) winner := newFakeAuth("alice").withAuthenticated() - m := security.NewManager(&scriptedAuthenticator{name: "violator", result: result}, - &scriptedAuthenticator{name: "ok", result: winner}) + m := security.NewManager(authenticatorReturning(t, result, nil), authenticatorReturning(t, winner, nil)) got, err := m.Authenticate(context.Background(), newFakeAuth("alice")) @@ -358,7 +305,7 @@ func TestManagerTreatsAMissingOrUnauthenticatedResultAsARefusal(t *testing.T) { func TestDecideDeniesANilAttribute(t *testing.T) { t.Parallel() - adm := security.NewAffirmativeDecisionManager(constDecisionVoter{d: security.DecisionGrant}) + adm := security.NewAffirmativeDecisionManager(decidingVoter(t, security.DecisionGrant)) admin := newFakeAuth("root", "ROLE_ADMIN").withAuthenticated() for name, attrs := range map[string][]security.Attribute{ @@ -379,7 +326,7 @@ func TestDecideDeniesANilAttribute(t *testing.T) { func TestDecideTreatsANilAuthenticationAsAnonymous(t *testing.T) { t.Parallel() - adm := security.NewAffirmativeDecisionManager(authReadingVoter{}) + adm := security.NewAffirmativeDecisionManager(authReadingVoter(t)) for name, auth := range map[string]security.Authentication{"nil": nil, "typed nil": typedNilAuth} { var err error @@ -397,9 +344,9 @@ func TestDecideTreatsANilAuthenticationAsAnonymous(t *testing.T) { func TestEngineTreatsATypedNilExtractionAsNoCredential(t *testing.T) { t.Parallel() - authenticator := &scriptedAuthenticator{name: "any", result: newFakeAuth("mallory").withAuthenticated()} - next := &countingExtractor{} - engine := security.NewEngine(security.NewManager(authenticator), scriptedExtractor{auth: typedNilAuth}, next) + authenticator := security.NewMockAuthenticator(t) // never consulted: any call fails the test + next := extractorReturning(t, nil, nil) + engine := security.NewEngine(security.NewManager(authenticator), extractorReturning(t, typedNilAuth, nil), next) var ( ctx context.Context @@ -411,8 +358,8 @@ func TestEngineTreatsATypedNilExtractionAsNoCredential(t *testing.T) { require.NoError(t, err) assert.Equal(t, security.Anonymous(), auth) - assert.Equal(t, 1, next.calls, "the next extractor is consulted") - assert.Zero(t, authenticator.calls(), "nothing was extracted") + next.AssertNumberOfCalls(t, "Extract", 1) // the next extractor is consulted + authenticator.AssertNotCalled(t, "Authenticate", mock.Anything, mock.Anything) stored, _ := security.FromContext(ctx) assert.Equal(t, security.Anonymous(), stored) @@ -423,10 +370,8 @@ func TestEngineTreatsATypedNilExtractionAsNoCredential(t *testing.T) { func TestEngineExtractorFailureLeavesNoCredentials(t *testing.T) { t.Parallel() - engine := security.NewEngine(security.NewManager(), scriptedExtractor{ - auth: newFakeAuth("alice").withCredentials("S3cr3t!"), - err: fmt.Errorf("malformed: %w", security.ErrInvalidRequest), - }) + engine := security.NewEngine(security.NewManager(), extractorReturning(t, + newFakeAuth("alice").withCredentials("S3cr3t!"), fmt.Errorf("malformed: %w", security.ErrInvalidRequest))) ctx, got, err := engine.Process(context.Background(), newMapCarrier()) @@ -445,25 +390,26 @@ func TestConstructorsCopyTheirSlices(t *testing.T) { alice := newFakeAuth("alice").withAuthenticated() - extractors := []security.Extractor{scriptedExtractor{auth: newFakeAuth("alice")}} - authenticators := []security.Authenticator{&scriptedAuthenticator{name: "ok", result: alice}} + extractors := []security.Extractor{extractorReturning(t, newFakeAuth("alice"), nil)} + authenticators := []security.Authenticator{authenticatorReturning(t, alice, nil)} engine := security.NewEngine(security.NewManager(authenticators...), extractors...) - extractors[0] = scriptedExtractor{err: security.ErrInvalidRequest} - authenticators[0] = &scriptedAuthenticator{name: "ko", err: security.ErrInvalidCredentials} + // The replacements are never consulted: any call fails the test. + extractors[0] = security.NewMockExtractor(t) + authenticators[0] = security.NewMockAuthenticator(t) _, got, err := engine.Process(context.Background(), newMapCarrier()) require.NoError(t, err) assert.Equal(t, security.Authentication(alice), got) - voters := []security.Voter{constDecisionVoter{d: security.DecisionGrant}} + voters := []security.Voter{decidingVoter(t, security.DecisionGrant)} adms := map[string]security.AccessDecisionManager{ "affirmative": security.NewAffirmativeDecisionManager(voters...), "consensus": security.NewConsensusDecisionManager(voters), "unanimous": security.NewUnanimousDecisionManager(voters), } - voters[0] = constDecisionVoter{d: security.DecisionDeny} + voters[0] = security.NewMockVoter(t) for name, adm := range adms { assert.NoError(t, adm.Decide(context.Background(), alice, []security.Attribute{stringAttr("x")}), name) @@ -475,8 +421,8 @@ func TestDecideCountsAnUndefinedDecisionAsDeny(t *testing.T) { attrs := []security.Attribute{stringAttr("x")} alice := newFakeAuth("alice").withAuthenticated() - undefined := constDecisionVoter{d: security.Decision(42)} - grant := constDecisionVoter{d: security.DecisionGrant} + undefined := decidingVoter(t, security.Decision(42)) + grant := decidingVoter(t, security.DecisionGrant) unanimous := security.NewUnanimousDecisionManager([]security.Voter{undefined}, security.WithAbstainFallback(security.DecisionGrant)) @@ -492,8 +438,8 @@ func TestConsensusDeniesWhenAllAbstain(t *testing.T) { t.Parallel() adm := security.NewConsensusDecisionManager([]security.Voter{ - constDecisionVoter{d: security.DecisionAbstain}, - constDecisionVoter{d: security.DecisionAbstain}, + decidingVoter(t, security.DecisionAbstain), + decidingVoter(t, security.DecisionAbstain), }, security.WithTieBreak(security.DecisionGrant)) err := adm.Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), []security.Attribute{stringAttr("x")}) @@ -504,7 +450,8 @@ func TestConsensusDeniesWhenAllAbstain(t *testing.T) { func TestConstructorsRefuseNilDependencies(t *testing.T) { t.Parallel() - voter := constDecisionVoter{d: security.DecisionGrant} + voter := security.NewMockVoter(t) + extractor := security.NewMockExtractor(t) cases := []struct { name string @@ -512,9 +459,9 @@ func TestConstructorsRefuseNilDependencies(t *testing.T) { fn func() }{ {"engine nil manager", "security: NewEngine: nil Manager", - func() { security.NewEngine(nil, scriptedExtractor{}) }}, + func() { security.NewEngine(nil, extractor) }}, {"engine nil extractor", "security: NewEngine: nil Extractor at index 1", - func() { security.NewEngine(security.NewManager(), scriptedExtractor{}, nil) }}, + func() { security.NewEngine(security.NewManager(), extractor, nil) }}, {"manager nil authenticator", "security: NewManager: nil Authenticator at index 0", func() { security.NewManager(nil) }}, {"affirmative nil voter", "security: NewAffirmativeDecisionManager: nil Voter at index 0", diff --git a/doubles_test.go b/doubles_test.go new file mode 100644 index 00000000..7dad9467 --- /dev/null +++ b/doubles_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package security_test + +import ( + "context" + "strings" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/stretchr/testify/mock" +) + +// Builders of the mockery mocks (`make generate`) used as interface doubles +// by the contract, decision and span tests. Authentication and Attribute +// values (fakeAuthentication, stringAttr) and the header-backed mapCarrier +// stay plain values: the tests compare them, they script no behavior. + +// Typed nils — the classic `var out *T; return out, nil`: a nil mock +// pointer satisfies the interface and panics on its first method call. +var ( + typedNilAuth security.Authentication = (*security.MockAuthentication)(nil) + typedNilAttr security.Attribute = (*security.MockAttribute)(nil) +) + +// decidingVoter supports every attribute and votes d (possibly undefined). +func decidingVoter(t *testing.T, d security.Decision) *security.MockVoter { + t.Helper() + + v := security.NewMockVoter(t) + v.EXPECT().Supports(mock.Anything).Return(true).Maybe() + v.EXPECT().Vote(mock.Anything, mock.Anything, mock.Anything).Return(d).Maybe() + + return v +} + +// prefixVoter supports the attributes starting with prefix and votes d. +func prefixVoter(t *testing.T, prefix string, d security.Decision) *security.MockVoter { + t.Helper() + + v := security.NewMockVoter(t) + v.EXPECT().Supports(mock.Anything).RunAndReturn(hasPrefix(prefix)).Maybe() + v.EXPECT().Vote(mock.Anything, mock.Anything, mock.Anything).Return(d).Maybe() + + return v +} + +func hasPrefix(prefix string) func(security.Attribute) bool { + return func(a security.Attribute) bool { return strings.HasPrefix(a.String(), prefix) } +} + +// authReadingVoter supports every attribute and reads the Authentication it +// is given: it proves that Decide never hands a voter a nil one. +func authReadingVoter(t *testing.T) *security.MockVoter { + t.Helper() + + v := security.NewMockVoter(t) + v.EXPECT().Supports(mock.Anything).Return(true) + v.EXPECT().Vote(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, auth security.Authentication, _ []security.Attribute) security.Decision { + if auth.IsAuthenticated() { + return security.DecisionGrant + } + + return security.DecisionDeny + }) + + return v +} + +// extractorReturning extracts (auth, err) and must be consulted. +func extractorReturning(t *testing.T, auth security.Authentication, err error) *security.MockExtractor { + t.Helper() + + e := security.NewMockExtractor(t) + e.EXPECT().Extract(mock.Anything, mock.Anything).Return(auth, err) + + return e +} + +// authenticatorReturning supports every credential, answers (result, err) +// and must be consulted. +func authenticatorReturning(t *testing.T, result security.Authentication, err error) *security.MockAuthenticator { + t.Helper() + + a := security.NewMockAuthenticator(t) + a.EXPECT().Supports(mock.Anything).Return(true) + a.EXPECT().Authenticate(mock.Anything, mock.Anything).Return(result, err) + + return a +} + +// managerReturning is a custom Manager answering (result, err). +func managerReturning(t *testing.T, result security.Authentication, err error) *security.MockManager { + t.Helper() + + m := security.NewMockManager(t) + m.EXPECT().Authenticate(mock.Anything, mock.Anything).Return(result, err) + + return m +} diff --git a/errorclass_test.go b/errorclass_test.go index 73b8740f..6f8c56f2 100644 --- a/errorclass_test.go +++ b/errorclass_test.go @@ -128,11 +128,9 @@ func spanNamed(t *testing.T, spans []sdktrace.ReadOnlySpan, name string) sdktrac func TestEngineSpanRecordsTheErrorClassNotTheMessage(t *testing.T) { const typed = "MyP@ssw0rd-typed-in-user-field" - authn := security.AuthenticatorFunc(func(context.Context, security.Authentication) (security.Authentication, error) { - return nil, fmt.Errorf("basic: load user %q: %w (%w)", typed, - errors.New("sql: no rows for email alice@example.com"), security.ErrInvalidCredentials) - }) - engine := security.NewEngine(security.NewManager(authn), scriptedExtractor{auth: newFakeAuth(typed).withCredentials("x")}) + authn := authenticatorReturning(t, nil, fmt.Errorf("basic: load user %q: %w (%w)", typed, + errors.New("sql: no rows for email alice@example.com"), security.ErrInvalidCredentials)) + engine := security.NewEngine(security.NewManager(authn), extractorReturning(t, newFakeAuth(typed).withCredentials("x"), nil)) spans := spanRecorder(func() { _, _, err := engine.Process(context.Background(), newMapCarrier()) @@ -157,7 +155,7 @@ func TestEngineSpanRecordsTheClassOfAnExtractorError(t *testing.T) { const secret = "Basic dXNlcjpTM2NyM3Qh" engine := security.NewEngine(security.NewManager(), - scriptedExtractor{err: fmt.Errorf("basic: bad header %q: %w", secret, security.ErrInvalidCredentials)}) + extractorReturning(t, nil, fmt.Errorf("basic: bad header %q: %w", secret, security.ErrInvalidCredentials))) spans := spanRecorder(func() { _, _, err := engine.Process(context.Background(), newMapCarrier()) @@ -187,13 +185,13 @@ func TestFailureSpansRecordTheirClass(t *testing.T) { return err }}, {"plain refusal", "security.Manager.Authenticate", security.ErrorClassOther, func() error { - _, err := security.NewManager(&scriptedAuthenticator{name: "x", err: errors.New("boom")}). + _, err := security.NewManager(authenticatorReturning(t, nil, errors.New("boom"))). Authenticate(context.Background(), newFakeAuth("alice")) return err }}, {"denied", "security.AccessDecisionManager.Decide", security.ErrorClassAccessDenied, func() error { - return security.NewAffirmativeDecisionManager(constDecisionVoter{d: security.DecisionDeny}). + return security.NewAffirmativeDecisionManager(decidingVoter(t, security.DecisionDeny)). Decide(context.Background(), newFakeAuth("alice").withAuthenticated(), []security.Attribute{stringAttr("x")}) }}, } @@ -214,9 +212,9 @@ func TestFailureSpansRecordTheirClass(t *testing.T) { func TestSuccessfulSpansCarryNoErrorType(t *testing.T) { authed := newFakeAuth("alice").withAuthenticated() - engine := security.NewEngine(security.NewManager(&scriptedAuthenticator{name: "ok", result: authed}), - scriptedExtractor{auth: newFakeAuth("alice")}) - adm := security.NewAffirmativeDecisionManager(constDecisionVoter{d: security.DecisionGrant}) + engine := security.NewEngine(security.NewManager(authenticatorReturning(t, authed, nil)), + extractorReturning(t, newFakeAuth("alice"), nil)) + adm := security.NewAffirmativeDecisionManager(decidingVoter(t, security.DecisionGrant)) spans := spanRecorder(func() { _, _, err := engine.Process(context.Background(), newMapCarrier()) From e5e12e095cf848461fc853bd6c464b06e16de023 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 03:24:58 +0200 Subject: [PATCH 24/29] test(http): use the generated ErrorMapper mock in the new authorize test Audit: CORE-10. --- http/mapping_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/http/mapping_test.go b/http/mapping_test.go index 609d1728..c3387514 100644 --- a/http/mapping_test.go +++ b/http/mapping_test.go @@ -16,6 +16,7 @@ import ( httpsec "github.com/hyperscale-stack/security/http" "github.com/hyperscale-stack/security/voter" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -248,13 +249,18 @@ func TestAuthorizeKeepsANonAccessErrorOfACustomManager(t *testing.T) { func TestAuthorizeWithUsesItsExplicitMapper(t *testing.T) { t.Parallel() - custom := &customMapper{} + custom := httpsec.NewMockErrorMapper(t) + custom.EXPECT().Map(mock.Anything, mock.Anything, mock.MatchedBy(func(err error) bool { + return errors.Is(err, security.ErrAccessDenied) + })). + Run(func(w http.ResponseWriter, _ *http.Request, _ error) { w.WriteHeader(http.StatusTeapot) }). + Once() + h := httpsec.Middleware(security.NewEngine(security.NewManager(), scriptedExtractor{}), httpsec.WithAnonymousFallback(true))( httpsec.AuthorizeWith(scriptedADM{err: security.ErrAccessDenied}, custom, fakeAttr("x"))(next204())) rec := serve(h, httptest.NewRequest(http.MethodGet, "/", nil)) - assert.True(t, custom.invoked.Load()) assert.Equal(t, http.StatusTeapot, rec.Code) } From 9d2f4c24782edd4a79a7f2242eb516792ab5fac0 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 03:24:58 +0200 Subject: [PATCH 25/29] test(grpc): use the generated ErrorMapper mock in the new tests Audit: CORE-05, CORE-10. --- grpc/contract_test.go | 4 ++-- grpc/hardening_test.go | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/grpc/contract_test.go b/grpc/contract_test.go index 266d59b3..93a9a614 100644 --- a/grpc/contract_test.go +++ b/grpc/contract_test.go @@ -161,8 +161,8 @@ func TestServerOptionsPassTheirOptionsToBothChains(t *testing.T) { attrs := []security.Attribute{security.Role("ADMIN")} own := dialWithOptions(t, slices.Concat( - grpcsec.ServerOptions(newEngine("ROLE_USER"), grpcsec.WithErrorMapper(fixedMapper{code: codes.Aborted})), - grpcsec.AuthorizeServerOptions(adm, attrs, grpcsec.WithErrorMapper(fixedMapper{code: codes.DataLoss})), + grpcsec.ServerOptions(newEngine("ROLE_USER"), grpcsec.WithErrorMapper(mapperAnswering(t, codes.Aborted))), + grpcsec.AuthorizeServerOptions(adm, attrs, grpcsec.WithErrorMapper(mapperAnswering(t, codes.DataLoss))), )...) assert.Equal(t, codes.Aborted, checkCode(own, context.Background()), "authentication mapper") diff --git a/grpc/hardening_test.go b/grpc/hardening_test.go index 511391d2..f4e47b61 100644 --- a/grpc/hardening_test.go +++ b/grpc/hardening_test.go @@ -17,6 +17,7 @@ import ( grpcsec "github.com/hyperscale-stack/security/grpc" "github.com/hyperscale-stack/security/voter" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -178,11 +179,16 @@ func TestInterceptorsFailClosedOnABrokenEngine(t *testing.T) { assert.Equal(t, codes.Internal, status.Code(err)) } -// recordingMapperFor answers every error with a fixed status and records -// that it was used. -type fixedMapper struct{ code codes.Code } +// mapperAnswering is an ErrorMapper mock answering every error with code; +// it must be used. +func mapperAnswering(t *testing.T, code codes.Code) *grpcsec.MockErrorMapper { + t.Helper() -func (m fixedMapper) Map(context.Context, error) error { return status.Error(m.code, "custom") } + m := grpcsec.NewMockErrorMapper(t) + m.EXPECT().Map(mock.Anything, mock.Anything).Return(status.Error(code, "custom")) + + return m +} // The authorization interceptors answer with the authentication // interceptor's mapper unless they were given their own. @@ -191,7 +197,7 @@ func TestAuthorizeUsesTheAuthenticationInterceptorsMapper(t *testing.T) { adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) attrs := []security.Attribute{security.Role("ADMIN")} - custom := grpcsec.WithErrorMapper(fixedMapper{code: codes.Aborted}) + custom := grpcsec.WithErrorMapper(mapperAnswering(t, codes.Aborted)) inherited := dialWithOptions(t, slices.Concat( grpcsec.ServerOptions(newEngine("ROLE_USER"), custom), @@ -203,7 +209,7 @@ func TestAuthorizeUsesTheAuthenticationInterceptorsMapper(t *testing.T) { own := dialWithOptions(t, slices.Concat( grpcsec.ServerOptions(newEngine("ROLE_USER"), custom), - grpcsec.AuthorizeServerOptions(adm, attrs, grpcsec.WithErrorMapper(fixedMapper{code: codes.DataLoss})), + grpcsec.AuthorizeServerOptions(adm, attrs, grpcsec.WithErrorMapper(mapperAnswering(t, codes.DataLoss))), )...) _, err = own.Check(bearer(context.Background(), "letmein"), &healthpb.HealthCheckRequest{}) From 955e38c956a25d6253f61d95799599664c35186c Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 03:24:58 +0200 Subject: [PATCH 26/29] test(connectrpc): use the generated ErrorMapper mock in the new tests Audit: CORE-10. --- connectrpc/hardening_test.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/connectrpc/hardening_test.go b/connectrpc/hardening_test.go index e359a301..500640ca 100644 --- a/connectrpc/hardening_test.go +++ b/connectrpc/hardening_test.go @@ -18,6 +18,7 @@ import ( connectrpcsec "github.com/hyperscale-stack/security/connectrpc" "github.com/hyperscale-stack/security/voter" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -217,10 +218,15 @@ func TestInterceptorFailsClosedOnABrokenEngine(t *testing.T) { assert.Equal(t, connect.CodeInternal, connect.CodeOf(err)) } -type fixedMapper struct{ code connect.Code } +// mapperAnswering is an ErrorMapper mock answering every error with code; +// it must be used. +func mapperAnswering(t *testing.T, code connect.Code) *connectrpcsec.MockErrorMapper { + t.Helper() + + m := connectrpcsec.NewMockErrorMapper(t) + m.EXPECT().Map(mock.Anything, mock.Anything).Return(connect.NewError(code, errors.New("custom"))) -func (m fixedMapper) Map(context.Context, error) error { - return connect.NewError(m.code, errors.New("custom")) + return m } func TestAuthorizationUsesTheAuthenticationInterceptorsMapper(t *testing.T) { @@ -229,13 +235,13 @@ func TestAuthorizationUsesTheAuthenticationInterceptorsMapper(t *testing.T) { adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) attrs := []security.Attribute{security.Role("ADMIN")} authn := connectrpcsec.NewAuthenticationInterceptor(newEngine("ROLE_USER"), - connectrpcsec.WithErrorMapper(fixedMapper{code: connect.CodeAborted})) + connectrpcsec.WithErrorMapper(mapperAnswering(t, connect.CodeAborted))) _, err := chainUnary(authn, connectrpcsec.NewAuthorizationInterceptor(adm, attrs), (&recordingUnary{}).fn)( context.Background(), unaryReq("letmein")) assert.Equal(t, connect.CodeAborted, connect.CodeOf(err)) - own := connectrpcsec.NewAuthorizationInterceptor(adm, attrs, connectrpcsec.WithErrorMapper(fixedMapper{code: connect.CodeDataLoss})) + own := connectrpcsec.NewAuthorizationInterceptor(adm, attrs, connectrpcsec.WithErrorMapper(mapperAnswering(t, connect.CodeDataLoss))) _, err = chainUnary(authn, own, (&recordingUnary{}).fn)(context.Background(), unaryReq("letmein")) assert.Equal(t, connect.CodeDataLoss, connect.CodeOf(err)) } From 0ec377fe5a49f7f97ef3afe219e34f92f24049f0 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 03:24:58 +0200 Subject: [PATCH 27/29] test(basic): use the generated UserLoader and PasswordUser mocks Audit: CORE-07, CORE-15, CORE-I6. --- basic/authorities_test.go | 2 +- basic/error_class_test.go | 6 +++++- basic/error_privacy_test.go | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/basic/authorities_test.go b/basic/authorities_test.go index c307929b..e73d0108 100644 --- a/basic/authorities_test.go +++ b/basic/authorities_test.go @@ -17,7 +17,7 @@ import ( func TestAuthoritiesReturnsACopy(t *testing.T) { t.Parallel() - auth := basic.New("alice", "pw").WithAuthenticated(&stubUser{subject: "alice"}, []string{"ROLE_USER"}) + auth := basic.New("alice", "pw").WithAuthenticated(basic.NewMockPasswordUser(t), []string{"ROLE_USER"}) auth.Authorities()[0] = "ROLE_ADMIN" diff --git a/basic/error_class_test.go b/basic/error_class_test.go index 830b6f9f..237d8bd0 100644 --- a/basic/error_class_test.go +++ b/basic/error_class_test.go @@ -13,6 +13,7 @@ import ( "github.com/hyperscale-stack/security" "github.com/hyperscale-stack/security/basic" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) // The UserLoader contract: an unavailable store is an outage (503), any @@ -28,7 +29,10 @@ func TestAuthenticatorClassifiesLoaderErrors(t *testing.T) { "unknown user": {fmt.Errorf("lookup: %w", security.ErrInvalidCredentials), security.ErrorClassInvalidCredentials}, "plain error": {errors.New("db: boom"), security.ErrorClassInvalidCredentials}, } { - a := basic.NewAuthenticator(&stubLoader{err: c.err}, newHasher(t)) + loader := basic.NewMockUserLoader(t) + loader.EXPECT().LoadByUsername(mock.Anything, "alice").Return(nil, c.err).Once() + + a := basic.NewAuthenticator(loader, newHasher(t)) _, err := a.Authenticate(context.Background(), basic.New("alice", "p4ss")) diff --git a/basic/error_privacy_test.go b/basic/error_privacy_test.go index 4916cdb8..b562ea28 100644 --- a/basic/error_privacy_test.go +++ b/basic/error_privacy_test.go @@ -12,6 +12,7 @@ import ( "github.com/hyperscale-stack/security" "github.com/hyperscale-stack/security/basic" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -22,7 +23,10 @@ func TestAuthenticatorErrorDoesNotEchoTheUsername(t *testing.T) { const typed = "Summer2026!-my-password" - a := basic.NewAuthenticator(&stubLoader{err: errors.New("store unreachable")}, newHasher(t)) + loader := basic.NewMockUserLoader(t) + loader.EXPECT().LoadByUsername(mock.Anything, typed).Return(nil, errors.New("store unreachable")).Once() + + a := basic.NewAuthenticator(loader, newHasher(t)) _, err := a.Authenticate(context.Background(), basic.New(typed, "x")) From d8c094d887b37e6c598ed9405dbec4d62f782802 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 03:24:58 +0200 Subject: [PATCH 28/29] test(bearer): use the generated TokenVerifier mock Audit: CORE-I6. --- bearer/error_class_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bearer/error_class_test.go b/bearer/error_class_test.go index 8e3d07c2..14fd5c4a 100644 --- a/bearer/error_class_test.go +++ b/bearer/error_class_test.go @@ -13,6 +13,7 @@ import ( "github.com/hyperscale-stack/security" "github.com/hyperscale-stack/security/bearer" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) // The TokenVerifier contract: the verifier's sentinel decides the answer — @@ -30,9 +31,10 @@ func TestAuthenticatorKeepsTheVerifiersErrorClass(t *testing.T) { "key set out": {fmt.Errorf("jwks: %w", security.ErrUnavailable), security.ErrorClassUnavailable}, "no sentinel": {errors.New("verifier bug"), security.ErrorClassOther}, } { - a := bearer.NewAuthenticator(bearer.VerifierFunc(func(context.Context, string) (security.Authentication, error) { - return nil, c.err - })) + verifier := bearer.NewMockTokenVerifier(t) + verifier.EXPECT().Verify(mock.Anything, "tk").Return(nil, c.err).Once() + + a := bearer.NewAuthenticator(verifier) _, err := a.Authenticate(context.Background(), bearer.New("tk")) From 290cbd14a0bbf10821a11a1b489e525eb7942dcd Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 03:24:58 +0200 Subject: [PATCH 29/29] test(session): use the generated PrincipalLoader mock Audit: CORE-I6. --- session/error_class_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/session/error_class_test.go b/session/error_class_test.go index 2d903701..f8cddec7 100644 --- a/session/error_class_test.go +++ b/session/error_class_test.go @@ -11,7 +11,9 @@ import ( "testing" "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/session" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -33,7 +35,10 @@ func TestSessionAuthenticatorKeepsTheLoadersErrorClass(t *testing.T) { "store down": {fmt.Errorf("db: %w", security.ErrUnavailable), security.ErrorClassUnavailable}, "no sentinel": {errors.New("loader bug"), security.ErrorClassOther}, } { - _, _, err := engineFor(mgr, stubLoader{err: c.err}).Process(context.Background(), loginCarrier.replay()) + loader := session.NewMockPrincipalLoader(t) + loader.EXPECT().Load(mock.Anything, mock.Anything).Return(nil, nil, c.err).Once() + + _, _, err := engineFor(mgr, loader).Process(context.Background(), loginCarrier.replay()) assert.Equal(t, c.want, security.ErrorClass(err), name) }