diff --git a/CHANGELOG.md b/CHANGELOG.md index b9698541..8fcfbae6 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, @@ -432,6 +432,33 @@ 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). +- `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. +- `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. +- `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`). +- `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 @@ -598,6 +625,87 @@ 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` — 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`, + `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 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` + 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). +- `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). +- 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); 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 + `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, 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, + `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 — 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); @@ -710,6 +818,30 @@ 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) — 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 — 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 + on a refused call and on a handler error too, and a custom `Engine` + 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 + 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 @@ -736,6 +868,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 @@ -1132,3 +1267,56 @@ 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. +- 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. +- `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. +- 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. +- `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` + (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. +- 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. +- 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. +- `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. +- 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/CLAUDE.md b/CLAUDE.md index 0f48d110..f78c4260 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. @@ -207,10 +210,18 @@ 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. 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`); + 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 @@ -218,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/LIMITATIONS.md b/LIMITATIONS.md index 8397aa52..d971f153 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,20 @@ 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`. +- **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 @@ -351,13 +363,19 @@ 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 - **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 e4582be1..a0108f37 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1260,3 +1260,275 @@ 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)` 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. + +| Situation | Before | After | +| --- | --- | --- | +| `Engine.Process` / `Manager.Authenticate` fail | return (and store) the extracted value | return (and store) `security.Anonymous()` | +| 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 (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 | + +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. +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 | +| Unanimous with `WithAbstainFallback(DecisionGrant)`, no attribute or no voter | granted, anonymous callers included | 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, 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`. + `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")]`. 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`. +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`. + +### `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. + +### `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` 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) | panic on every request | panic at construction | +| Reusing the attribute slice passed to `Authorize` | rewrote the route's policy | no effect (copied) | + +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. + +### `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 | +| 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` | 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: + +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. + +### `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` | +| `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` | panic on every RPC | panic at construction; the attributes are copied | + +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/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/access_decision_manager.go b/access_decision_manager.go index eb45a421..e5c63c33 100644 --- a/access_decision_manager.go +++ b/access_decision_manager.go @@ -6,22 +6,35 @@ package security import ( "context" + "fmt" + "slices" "strings" - "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) // 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] (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, 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 { @@ -45,24 +58,28 @@ 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 } } // 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 +88,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 +105,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 +117,12 @@ 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]. 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() @@ -108,28 +132,85 @@ func (m *accessDecisionManager) Decide(ctx context.Context, auth Authentication, AttrAttributes.String(joinAttributes(attrs)), ) + if isNil(auth) { + auth = Anonymous() + } + + final := m.decision(ctx, span, auth, attrs) + + span.SetAttributes(AttrDecision.String(final.String())) + + if final == DecisionGrant { + return nil + } + + recordFailure(span, ErrAccessDenied) + + 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 + + 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, 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, v.Vote(ctx, auth, attrs)) + votes = append(votes, normalize(v.Vote(ctx, auth, supported))) } - 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,14 +290,22 @@ func unanimous(allAbstainFallback Decision) func([]Decision) Decision { } } -func anySupported(v Voter, attrs []Attribute) bool { +// containsNil reports whether attrs holds a nil (or typed nil) Attribute. +func containsNil(attrs []Attribute) bool { + return slices.ContainsFunc(attrs, func(a Attribute) bool { return isNil(a) }) +} + +// 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 { @@ -226,13 +315,27 @@ func joinAttributes(attrs []Attribute) string { parts := make([]string, len(attrs)) for i, a := range attrs { + if isNil(a) { + 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/access_decision_manager_supported_test.go b/access_decision_manager_supported_test.go new file mode 100644 index 00000000..29fe9502 --- /dev/null +++ b/access_decision_manager_supported_test.go @@ -0,0 +1,155 @@ +// 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/mock" + "github.com/stretchr/testify/require" +) + +// 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(prefixVoter(t, "scope:", security.DecisionGrant)), + "consensus": security.NewConsensusDecisionManager([]security.Voter{ + prefixVoter(t, "scope:", security.DecisionGrant), + }, security.WithTieBreak(security.DecisionGrant)), + "unanimous": security.NewUnanimousDecisionManager([]security.Voter{ + prefixVoter(t, "scope:", 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(prefixVoter(t, "scope:", 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() + + // 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) +} + +// 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 := prefixVoter(t, "scope:", 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) + } + } +} + +// 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 := 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}, + 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() + + adm := security.NewConsensusDecisionManager([]security.Voter{ + 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")}) + + 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/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..e5d679f7 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 @@ -17,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) } @@ -33,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) } @@ -80,3 +92,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/authentication.go b/authentication.go index 066ee521..2e836d4d 100644 --- a/authentication.go +++ b/authentication.go @@ -38,8 +38,11 @@ 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. + // 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/authenticator.go b/authenticator.go index 78f4b95f..5c605c39 100644 --- a/authenticator.go +++ b/authenticator.go @@ -16,11 +16,24 @@ 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 — 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 (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/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/authenticator.go b/basic/authenticator.go index f05fea2e..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 { @@ -79,8 +85,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/authorities_test.go b/basic/authorities_test.go new file mode 100644 index 00000000..e73d0108 --- /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(basic.NewMockPasswordUser(t), []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/basic/error_class_test.go b/basic/error_class_test.go new file mode 100644 index 00000000..237d8bd0 --- /dev/null +++ b/basic/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 basic_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "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 +// 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}, + } { + 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")) + + assert.Equal(t, c.want, security.ErrorClass(err), name) + } +} diff --git a/basic/error_privacy_test.go b/basic/error_privacy_test.go new file mode 100644 index 00000000..b562ea28 --- /dev/null +++ b/basic/error_privacy_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 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/mock" + "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" + + 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")) + + require.Error(t, err) + assert.ErrorIs(t, err, security.ErrInvalidCredentials) + assert.NotContains(t, err.Error(), typed) +} 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/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) } 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/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/bearer/error_class_test.go b/bearer/error_class_test.go new file mode 100644 index 00000000..14fd5c4a --- /dev/null +++ b/bearer/error_class_test.go @@ -0,0 +1,43 @@ +// 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" + "github.com/stretchr/testify/mock" +) + +// 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}, + } { + 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")) + + assert.Equal(t, c.want, security.ErrorClass(err), name) + } +} 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/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) } diff --git a/carrier.go b/carrier.go index fce27ef3..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. @@ -16,13 +18,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. @@ -31,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..938b105a --- /dev/null +++ b/carrier_test.go @@ -0,0 +1,48 @@ +// 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()) + + // 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/connectrpc/authorize.go b/connectrpc/authorize.go index 26ae4136..f0bbb4a3 100644 --- a/connectrpc/authorize.go +++ b/connectrpc/authorize.go @@ -6,6 +6,9 @@ package connectrpcsec import ( "context" + "errors" + "fmt" + "slices" "connectrpc.com/connect" "github.com/hyperscale-stack/security" @@ -22,8 +25,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,13 +37,25 @@ 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 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, opts ...Option, ) *AuthorizationInterceptor { - return &AuthorizationInterceptor{adm: adm, attrs: attrs, cfg: buildConfig(opts...)} + if adm == nil { + panic("connectrpcsec: NewAuthorizationInterceptor: nil AccessDecisionManager") + } + + 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. @@ -52,7 +70,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 +84,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 +108,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..ff63c5af 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,20 @@ 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)) +} + +// 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..511148fb --- /dev/null +++ b/connectrpc/challenge.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 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 { + 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 s != "" // token = 1*tchar +} + +// 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/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/connectrpc/contract_test.go b/connectrpc/contract_test.go new file mode 100644 index 00000000..095c3900 --- /dev/null +++ b/connectrpc/contract_test.go @@ -0,0 +1,208 @@ +// 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" + "errors" + "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) +} + +// 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) + } +} diff --git a/connectrpc/error_mapper.go b/connectrpc/error_mapper.go index 5db71c78..3a0df3c9 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,133 @@ 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 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) Bearer realm=R // -// The message is intentionally terse — Connect clients branch on the code, -// not the string. -func DefaultErrorMapper() ErrorMapper { return defaultErrorMapper{} } +// 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. +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 "". 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 m.bearer || 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..746a1b76 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,81 @@ 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", []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}}, + {"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", []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}, } - 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..500640ca --- /dev/null +++ b/connectrpc/hardening_test.go @@ -0,0 +1,274 @@ +// 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/mock" + "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)) +} + +// 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"))) + + return m +} + +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(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(mapperAnswering(t, 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..63fd4a3c 100644 --- a/connectrpc/interceptor.go +++ b/connectrpc/interceptor.go @@ -18,8 +18,14 @@ import ( const tracerName = "github.com/hyperscale-stack/security/connectrpc" +// errEngineContract reports a custom [security.Engine] that returned no +// 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 -// 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 +34,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 +63,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 || isNil(auth): + return ctx, carrier, errEngineContract + case !auth.IsAuthenticated() && !cfg.anonymousFallback: + return ctx, carrier, security.ErrAuthenticationRequired } span.SetAttributes(attribute.Bool("security.authenticated", auth.IsAuthenticated())) @@ -68,7 +86,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 +96,22 @@ 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 a nil engine and on an invalid challenge +// configuration (see [DefaultErrorMapper]). func NewAuthenticationInterceptor(engine security.Engine, opts ...Option) *AuthenticationInterceptor { - return &AuthenticationInterceptor{engine: engine, cfg: buildConfig(opts...)} + if engine == nil { + panic("connectrpcsec: NewAuthenticationInterceptor: nil Engine") + } + + cfg := buildConfig(opts...) + + return &AuthenticationInterceptor{engine: engine, cfg: cfg, mapper: cfg.ownMapper()} } // Compile-time check. @@ -99,12 +127,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 +149,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/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) + } +} 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/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 new file mode 100644 index 00000000..a2512a52 --- /dev/null +++ b/contract_test.go @@ -0,0 +1,478 @@ +// 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/mock" + "github.com/stretchr/testify/require" +) + +// 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, + "typed nil result": typedNilAuth, + "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(authenticatorReturning(t, result, nil)), + extractorReturning(t, newFakeAuth("alice").withCredentials("pw"), nil), + ) + + 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, + "typed nil result": typedNilAuth, + "unauthenticated result": newFakeAuth("alice").withCredentials("pw"), + } + + for name, result := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + engine := security.NewEngine( + managerReturning(t, result, nil), + extractorReturning(t, newFakeAuth("alice").withCredentials("pw"), nil), + ) + + 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() + + // 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(refusing), + "unsupported": security.NewManager(), + } + + for name, manager := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + engine := security.NewEngine(manager, extractorReturning(t, newFakeAuth("alice").withCredentials("S3cr3t!"), nil)) + + 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 := authenticatorReturning(t, nil, 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(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(t), authenticatorReturning(t, nil, 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(t), bailOut(t)) + + _, err := m.Authenticate(context.Background(), newFakeAuth("alice")) + + assert.ErrorIs(t, err, security.ErrUnsupportedCredential) + 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(authenticatorReturning(t, nil, 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() + + winner := newFakeAuth("alice").withAuthenticated() + m := security.NewManager(bailOut(t), authenticatorReturning(t, winner, nil)) + + 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, + "typed nil result": typedNilAuth, + "unauthenticated result": newFakeAuth("alice"), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + violator := authenticatorReturning(t, result, nil) + + 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) + assertNoCredentialSentinel(t, err) + + winner := newFakeAuth("alice").withAuthenticated() + m := security.NewManager(authenticatorReturning(t, result, nil), authenticatorReturning(t, winner, nil)) + + 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(decidingVoter(t, 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")}, + "only typed nil": {typedNilAttr}, + "typed nil after valid": {stringAttr("role:ADMIN"), typedNilAttr}, + "typed nil before valid": {typedNilAttr, 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(t)) + + 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 := 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 + 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) + 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) +} + +// 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(), extractorReturning(t, + newFakeAuth("alice").withCredentials("S3cr3t!"), fmt.Errorf("malformed: %w", security.ErrInvalidRequest))) + + 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{extractorReturning(t, newFakeAuth("alice"), nil)} + authenticators := []security.Authenticator{authenticatorReturning(t, alice, nil)} + engine := security.NewEngine(security.NewManager(authenticators...), extractors...) + + // 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{decidingVoter(t, security.DecisionGrant)} + adms := map[string]security.AccessDecisionManager{ + "affirmative": security.NewAffirmativeDecisionManager(voters...), + "consensus": security.NewConsensusDecisionManager(voters), + "unanimous": security.NewUnanimousDecisionManager(voters), + } + + voters[0] = security.NewMockVoter(t) + + for name, adm := range adms { + assert.NoError(t, adm.Decide(context.Background(), alice, []security.Attribute{stringAttr("x")}), name) + } +} + +func TestDecideCountsAnUndefinedDecisionAsDeny(t *testing.T) { + t.Parallel() + + attrs := []security.Attribute{stringAttr("x")} + alice := newFakeAuth("alice").withAuthenticated() + undefined := decidingVoter(t, security.Decision(42)) + grant := decidingVoter(t, 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{ + decidingVoter(t, security.DecisionAbstain), + decidingVoter(t, 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 := security.NewMockVoter(t) + extractor := security.NewMockExtractor(t) + + cases := []struct { + name string + want string + fn func() + }{ + {"engine nil manager", "security: NewEngine: nil Manager", + func() { security.NewEngine(nil, extractor) }}, + {"engine nil extractor", "security: NewEngine: nil Extractor at index 1", + 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", + 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/docs/architecture.md b/docs/architecture.md index c7f9a00c..a0d2faa2 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 @@ -145,17 +147,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 @@ -163,15 +175,19 @@ 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`. + 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 - sentinels into `connect.Code`. + `security.ErrorClass` into `connect.Code` with the `WWW-Authenticate` + challenge in the error's metadata. ## OAuth2 diff --git a/docs/observability.md b/docs/observability.md index 2c7c8874..8fd497a1 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); `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 +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 @@ -146,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 | @@ -160,7 +176,9 @@ it delegates to `security.AccessDecisionManager.Decide`. | `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` @@ -222,7 +240,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..8824529b 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,16 +1095,116 @@ 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). + +## 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: + +- **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. +- **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 — 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. + 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 + 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 -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) | gRPC / ConnectRPC | +| --- | --- | --- | --- | +| `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 | 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` +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 +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 +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. 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 -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. @@ -1225,6 +1329,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. 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/engine.go b/engine.go index 9edee989..e072d911 100644 --- a/engine.go +++ b/engine.go @@ -6,11 +6,17 @@ 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 +25,42 @@ 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 — 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. 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) @@ -47,8 +80,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()) @@ -60,15 +92,14 @@ 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()) return ctx, Anonymous(), err } - if auth != nil { + if !isNil(auth) { extracted = auth break @@ -84,22 +115,35 @@ 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. - span.SetStatus(codes.Error, err.Error()) + // The extracted value carries the presented credentials (a + // cleartext password, a bearer token): it is dropped here and the + // context gets the anonymous authentication. + recordFailure(span, err) - 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: nil, a typed nil or an +// unauthenticated value. +func requireAuthenticated(auth Authentication, component string) error { + if isNil(auth) || !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/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..6f8c56f2 --- /dev/null +++ b/errorclass_test.go @@ -0,0 +1,231 @@ +// 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 := 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()) + 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(), + extractorReturning(t, nil, 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(authenticatorReturning(t, nil, errors.New("boom"))). + Authenticate(context.Background(), newFakeAuth("alice")) + + return err + }}, + {"denied", "security.AccessDecisionManager.Decide", security.ErrorClassAccessDenied, func() error { + return security.NewAffirmativeDecisionManager(decidingVoter(t, 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(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()) + 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/errors.go b/errors.go index 3a85bd47..6d2286a9 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,79 @@ 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 — + // 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 // [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/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/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/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/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/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) + }) + } +} 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()) + }) +} 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/extractor.go b/extractor.go index 03bceaa6..6f49c9ee 100644 --- a/extractor.go +++ b/extractor.go @@ -14,12 +14,17 @@ 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 // 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/grpc/authorize.go b/grpc/authorize.go index 3540e610..4343bd47 100644 --- a/grpc/authorize.go +++ b/grpc/authorize.go @@ -6,6 +6,9 @@ package grpcsec import ( "context" + "errors" + "fmt" + "slices" "github.com/hyperscale-stack/security" "go.opentelemetry.io/otel" @@ -15,15 +18,22 @@ 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 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 { + attrs = mustPolicy(adm, attrs) + cfg := buildConfig(opts...) return func( @@ -33,19 +43,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 +// copies attrs, and panics if adm or one of attrs is nil. func StreamAuthorize( adm security.AccessDecisionManager, attrs []security.Attribute, opts ...Option, ) grpc.StreamServerInterceptor { + attrs = mustPolicy(adm, attrs) + cfg := buildConfig(opts...) return func( @@ -55,7 +68,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 +84,37 @@ 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 +} + +// 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/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..37bcb352 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,28 @@ 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)) +} + +// 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/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/grpc/contract_test.go b/grpc/contract_test.go new file mode 100644 index 00000000..93a9a614 --- /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(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") + 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/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..f4e47b61 --- /dev/null +++ b/grpc/hardening_test.go @@ -0,0 +1,290 @@ +// 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/mock" + "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)) +} + +// 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() + + 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. +func TestAuthorizeUsesTheAuthenticationInterceptorsMapper(t *testing.T) { + t.Parallel() + + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) + attrs := []security.Attribute{security.Role("ADMIN")} + custom := grpcsec.WithErrorMapper(mapperAnswering(t, 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(mapperAnswering(t, 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..dbe11aa0 100644 --- a/grpc/interceptor.go +++ b/grpc/interceptor.go @@ -17,9 +17,15 @@ import ( const tracerName = "github.com/hyperscale-stack/security/grpc" +// errEngineContract reports a custom [security.Engine] that returned no +// 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 -// 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 +34,43 @@ 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 || isNil(auth): + 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. +// 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() return func( ctx context.Context, @@ -64,20 +78,31 @@ 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. It panics if engine is nil. func StreamServerInterceptor(engine security.Engine, opts ...Option) grpc.StreamServerInterceptor { + mustEngine(engine) + cfg := buildConfig(opts...) + mapper := cfg.ownMapper() return func( srv any, @@ -85,12 +110,27 @@ 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)}) + } +} + +// 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") } } 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/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..bfbc49a8 --- /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. It panics if +// engine is nil. +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 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 { + 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 } diff --git a/http/authorize.go b/http/authorize.go index c534bd92..6cd0b9cc 100644 --- a/http/authorize.go +++ b/http/authorize.go @@ -5,7 +5,10 @@ package httpsec import ( + "errors" + "fmt" "net/http" + "slices" "github.com/hyperscale-stack/security" ) @@ -14,31 +17,42 @@ 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 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 { - return AuthorizeWith(adm, DefaultErrorMapper("Bearer", ""), attrs...) + 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", "") - } + attrs = mustPolicy(adm, attrs) + 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 +61,31 @@ 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. +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_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/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/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/challenge.go b/http/challenge.go new file mode 100644 index 00000000..5452e68a --- /dev/null +++ b/http/challenge.go @@ -0,0 +1,73 @@ +// 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 { + 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 s != "" // token = 1*tchar +} + +// 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_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/challenge_test.go b/http/challenge_test.go new file mode 100644 index 00000000..daa5070d --- /dev/null +++ b/http/challenge_test.go @@ -0,0 +1,145 @@ +// 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, 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)}, + {"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, 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"`)}, + } + + 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}, + {"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"`}, + } { + 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/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/http/error_mapper.go b/http/error_mapper.go index 7f075baa..aac15e34 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,131 @@ 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 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 Bearer realm=R // -// - 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 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. // -// 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 + } -type defaultErrorMapper struct { - scheme string - realm string -} + if !isToken(scheme) { + panic(fmt.Sprintf("httpsec: challenge scheme %q is not an RFC 9110 token", scheme)) + } -// Map implements [ErrorMapper]. -func (m *defaultErrorMapper) Map(w http.ResponseWriter, _ *http.Request, err error) { - status, oauthErr, desc := classify(err) + quoted, ok := quoteString(realm) + if !ok { + panic(fmt.Sprintf("httpsec: realm %q is not printable ASCII (RFC 9110 §5.6.4 quoted-string)", realm)) + } - if status == http.StatusUnauthorized || status == http.StatusForbidden { - w.Header().Set("WWW-Authenticate", m.challenge(oauthErr, desc)) + params := "realm=" + quoted + if strings.EqualFold(scheme, "Basic") { + params += `, charset="UTF-8"` } - http.Error(w, http.StatusText(status), status) + return &defaultErrorMapper{ + challenge: scheme + " " + params, + bearer: strings.EqualFold(scheme, defaultChallengeScheme), + } } -// 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 +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 +} - if m.realm != "" { - out += fmt.Sprintf(" realm=%q", m.realm) - } +// Map implements [ErrorMapper]. +func (m *defaultErrorMapper) Map(w http.ResponseWriter, r *http.Request, err error) { + o := outcomeOf(security.ErrorClass(err)) - if oauthErr != "" { - sep := " " - if m.realm != "" { - sep = ", " - } + if challenge := m.challengeFor(o, r); challenge != "" { + w.Header().Set("WWW-Authenticate", challenge) + } - out += sep + fmt.Sprintf("error=%q", oauthErr) + http.Error(w, http.StatusText(o.status), o.status) +} - if desc != "" { - out += fmt.Sprintf(`, error_description=%q`, desc) - } +// 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 { + 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 + 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/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 new file mode 100644 index 00000000..c3387514 --- /dev/null +++ b/http/mapping_test.go @@ -0,0 +1,304 @@ +// 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/mock" + "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.Equal(t, []string{bearerAPI}, rec.Header().Values("WWW-Authenticate"), "RFC 6750 §3: %s", 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 := 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.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")) +} + +// 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.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 8bcc167d..8484312d 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,19 @@ 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 (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. // // On success the next handler runs with the request context enriched via @@ -33,8 +39,18 @@ 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 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 { @@ -42,35 +58,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))) - carrier := NewCarrier(w, r.WithContext(ctx)) + if route := httpRoute(r.Pattern); route != "" { + span.SetAttributes(attrHTTPRoute.String(route)) + } - newCtx, auth, err := engine.Process(ctx, carrier) - if err != nil && !isNoCredential(err, cfg) { + ctx = withErrorMapper(ctx, cfg.errorMapper) + + newCtx, auth, err := engine.Process(ctx, NewCarrier(w, r.WithContext(ctx))) + + switch { + case err != nil && !isNoCredential(err, cfg): cfg.errorMapper.Map(w, r, err) return - } + case newCtx == nil || isNil(auth): + 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 +120,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 "" +} + +// 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 r.URL.Path + 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/nil.go b/http/nil.go new file mode 100644 index 00000000..ab866b66 --- /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: 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/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) + } +} 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) + } +} diff --git a/manager.go b/manager.go index aa115871..bed3f54d 100644 --- a/manager.go +++ b/manager.go @@ -8,21 +8,29 @@ import ( "context" "errors" "fmt" - - "go.opentelemetry.io/otel/codes" ) // Manager orchestrates a chain of [Authenticator]s with first-success-wins // 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 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 +// 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,25 @@ func (m *manager) Authenticate(ctx context.Context, auth Authentication) (Authen return result, nil } - errs = append(errs, fmt.Errorf("%s: %w", name, err)) + 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 + } + + refusals = append(refusals, fmt.Errorf("%s: %w", name, err)) } - if !anySupported { - err := ErrUnsupportedCredential - span.SetStatus(codes.Error, err.Error()) - span.RecordError(err) + if len(refusals) == 0 { + recordFailure(span, ErrUnsupportedCredential) - return auth, err + return Anonymous(), ErrUnsupportedCredential } - joined := errors.Join(errs...) - 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 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) { 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) + } +} diff --git a/otel.go b/otel.go index 9da6a8df..70ba39fc 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,14 @@ 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") + + // 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/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/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/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()) +} 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/error_class_test.go b/session/error_class_test.go new file mode 100644 index 00000000..f8cddec7 --- /dev/null +++ b/session/error_class_test.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 session_test + +import ( + "context" + "errors" + "fmt" + "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" +) + +// 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}, + } { + 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) + } +} 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} 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..b7cf305e 100644 --- a/voter/authority.go +++ b/voter/authority.go @@ -5,50 +5,39 @@ 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). It panics if +// name is empty. func HasAuthority(name string) security.Voter { - return authorityVoter{wanted: []string{name}, anyOf: false} + return newAuthorityVoter("HasAuthority", 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. It panics without an authority or on an empty one. func HasAnyAuthority(names ...string) security.Voter { - return authorityVoter{wanted: names, anyOf: true} + return newAuthorityVoter("HasAnyAuthority", names...) } -type authorityVoter struct { - wanted []string - anyOf bool -} - -func (v authorityVoter) Supports(a security.Attribute) bool { - _, 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 +func newAuthorityVoter(constructor string, names ...string) security.Voter { + return nameVoter{ + names: mustNames(constructor, "authority", names, checkAuthority), + nameOf: authorityName, + holds: slices.Contains[[]string], } +} - for _, want := range v.wanted { - if slices.Contains(auth.Authorities(), want) { - return security.DecisionGrant - } - - if !v.anyOf { - break - } - } +// authorityName returns the name of an authority attribute. +func authorityName(a security.Attribute) (string, bool) { + n, ok := a.(security.AuthorityAttribute) - 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..c8c35313 100644 --- a/voter/composite.go +++ b/voter/composite.go @@ -6,51 +6,82 @@ 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", voters, func(votes []security.Decision, _ security.Authentication) security.Decision { + return and(votes) + }) } // 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", voters, func(votes []security.Decision, _ security.Authentication) security.Decision { + return or(votes) + }) } -// 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", []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 } -// 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, + voters []security.Voter, + combine func([]security.Decision, security.Authentication) security.Decision, +) 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), combine: combine} +} + +// 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 +92,85 @@ func (c compositeVoter) Supports(a security.Attribute) bool { return false } +// Vote combines the inner votes. 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) { + votes := make([]security.Decision, len(c.voters)) + + for i, v := range c.voters { + votes[i] = v.Vote(ctx, auth, attrs) + } + + return c.combine(votes, auth) +} + +// 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/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) + } +} 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/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/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..22b121e3 100644 --- a/voter/role.go +++ b/voter/role.go @@ -5,72 +5,51 @@ 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 +// 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 roleVoter{wanted: []string{role}, anyOf: false} + return newRoleVoter("HasRole", 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]; it panics without a role +// or on a role HasRole refuses. func HasAnyRole(roles ...string) security.Voter { - return roleVoter{wanted: roles, anyOf: true} + return newRoleVoter("HasAnyRole", roles...) } -type roleVoter struct { - wanted []string - anyOf bool -} - -func (v roleVoter) Supports(a security.Attribute) bool { - _, 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 +func newRoleVoter(constructor string, roles ...string) security.Voter { + return nameVoter{ + names: mustNames(constructor, "role", roles, checkRole), + nameOf: roleName, + holds: hasRole, } +} - 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 +// roleName returns the bare name of a role attribute. +func roleName(a security.Attribute) (string, bool) { + r, ok := a.(security.RoleAttribute) - return security.DecisionDeny + 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 d3034937..b28c85ca 100644 --- a/voter/role_test.go +++ b/voter/role_test.go @@ -18,21 +18,23 @@ 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) { +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) { @@ -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", "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 { 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..9ed3b508 100644 --- a/voter/scope.go +++ b/voter/scope.go @@ -5,81 +5,52 @@ 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 +// 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") -// -// Unauthenticated requests always vote Deny; non-scope attributes Abstain. +// 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 scopeVoter{wanted: []string{scope}, anyOf: false} + return newScopeVoter("HasScope", 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]; it panics without a scope or on a +// scope HasScope refuses. func HasAnyScope(scopes ...string) security.Voter { - return scopeVoter{wanted: scopes, anyOf: true} + return newScopeVoter("HasAnyScope", scopes...) } -type scopeVoter struct { - wanted []string - anyOf bool +func newScopeVoter(constructor string, scopes ...string) security.Voter { + return nameVoter{ + names: mustNames(constructor, "scope", scopes, checkScope), + nameOf: scopeName, + holds: hasScope, + } } -func (v scopeVoter) Supports(a security.Attribute) bool { - _, ok := a.(security.ScopeAttribute) +// scopeName returns the name of a scope attribute. +func scopeName(a security.Attribute) (string, bool) { + s, 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 - } - } - - return security.DecisionDeny + 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 eec2a986..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 { @@ -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", "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) { 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) }) +}