Skip to content

Latest commit

 

History

History
496 lines (456 loc) · 31.8 KB

CLAUDE.md

File metadata and controls

496 lines (456 loc) · 31.8 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

github.com/hyperscale-stack/security is a transport-agnostic authentication and authorization framework for Go — conceptually Spring Security / Symfony Security for the Go ecosystem. It is a Go workspace (go.work) split into several independently-releasable modules, so consumers import only the pieces they need and the core stays free of heavy transitive dependencies.

The whole v0.x series was replaced by a ground-up transport-agnostic rewrite; the legacy authentication/ / authorization/ packages were removed. The rewrite is functionally complete and currently [Unreleased]: its first release is v1.0.0 for every module (the module paths carry no /vN suffix; see docs/releasing.md). Remaining gaps are tracked as GitHub issues, not refactor phases. Source-of-truth docs:

  • CHANGELOG.md — what the first release ships.
  • MIGRATION.md — workspace layout and dependency policy.
  • LIMITATIONS.md — known gaps and explicitly out-of-scope items.
  • docs/ — architecture.md, oidc-provider.md, oidc-client.md, oidc-conformance.md, saml-sp.md, observability.md, security-considerations.md, migration-from-v0.md, releasing.md.

Working rules

These rules are mandatory when working in this repository:

  • Clean code: write clean, SOLID, testable code. No overengineering — build for the current requirement, not a hypothetical one.
  • Language: all code, commit messages, and documentation are in English. Talk to the user (Axel) in French.
  • Tests are mandatory: always write tests. Target 100% coverage where practical, never below 80%.
  • RFC compliance: anything based on an RFC (OAuth2, JWT, PKCE, ...) MUST follow the RFC to the letter.
  • Security: write secure code with no known vulnerabilities. gosec is part of the lint gate — keep it clean.
  • Per-step gate: at the end of every feature/step, run make test lint check-modules (and make vuln before opening a pull request). They MUST pass. make generate runs on its own through test, lint, vuln and tidy.
  • Commit what make tidy / go work sync change. CI fails when a go.mod or go.sum is out of sync with the workspace or untidy on its own (make check-modules). To resolve a go.mod conflict: take either side, run make tidy, then make check-modules, and commit.
  • Commit per step: once tests and lint pass, commit the step before moving on. Use Conventional Commits with a module scope, as in the git history (feat(oauth2): ..., fix(jwt): ..., docs(...): ...).
  • Godoc: document every public API with godoc. Keep it concise and relevant so a developer can pick up the module quickly.

Commands

All targets operate on every module in the workspace (discovered via find . -name go.mod). Run from the repo root:

make build      # go build ./... in every module
make generate   # mockery mocks (in-package mock_*_test.go, never committed), see "Tooling caveats"
make test       # generate, then go test -race -cover in every module, aggregated -> build/coverage.out
make lint       # golangci-lint (pinned in tools/golangci-lint, shared .golangci.yml) on every module
make vuln       # govulncheck -test on every module, outside the workspace
make test-backends # the store suites on real PostgreSQL/MySQL/MariaDB/Redis (variables: scripts/test-backends.sh); CI runs it
make tools      # build the pinned tools (tools/<name>) into build/bin
make tidy       # go work sync + go mod tidy per module (and tools/*) + go work sync
make check-modules # one go line, go.work complete, sync no-op, every module tidy/buildable with GOWORK=off
make release-check TAG=oauth2/v1.0.0 # pre-push check of a release tag (docs/releasing.md)
make sync       # go work sync
make bench      # benchmarks across all modules
make coverage   # go tool cover -func on the aggregated profile
make e2e        # opt-in browser suites (Tales + Chrome: e2e/saml, e2e/oidc), never in CI
make e2e-oidc   # only the OIDC one (make e2e-saml: only the SAML one)
make conformance # opt-in: the OpenID Foundation suite against oidc/provider and oidc/client (Docker, OIDF_SUITE_DIR)

To run a single test or work on one module, cd into that module first (each module is its own go.mod with replace directives back to the core). Run make generate once before running go test directly: the tests use the generated mocks.

make generate
cd oauth2 && go test -race -run TestServer_Token ./...
cd jwt    && go test ./...

make test aggregates coverage but excludes example main() programs (they bind a socket and block); examples are still built, tested, and linted.

CI (read-only token by default, only the release publish job gets contents: write; every action pinned to a commit SHA; credentials never persisted; keep it actionlint- and zizmor-clean):

  • .github/workflows/go.yml runs make tools generate check-modules build test lint against the whole workspace, then make coveralls (goveralls, pinned in tools/goveralls; a Coveralls outage does not fail the build); a second job, Store suites (real backends), runs make test-backends against PostgreSQL 16, MySQL 8.0/8.4, MariaDB 11.4 and Redis 7.4 service containers (digests of the compose files, loopback only, no credentials). A real-backend test skips while its variable is unset, and that run fails on any skip: a new real-backend test follows that rule, its variable is added to scripts/test-backends.sh and to the job, and a package outside the suites the script lists gets a suite line there (otherwise it never runs in CI and the job stays green);
  • govulncheck.yml runs make vuln (every module, tests included) on every change and weekly;
  • release.yml, on a v* or **/v* tag: a read-only verify job (scripts/release-check.sh, check-modules, build, test, lint, and govulncheck on the released module), then a publish job (contents: write, environment release, gh release create, no checkout). See docs/releasing.md.

Module layout & dependency policy

Path Import / pkg name Purpose
. security Core transport-agnostic primitives
./outbound .../outbound SSRF-safe outbound *http.Client — package of the core
./http .../http → httpsec net/http adapter (middleware, Authorize, carrier)
./grpc .../grpc → grpcsec gRPC unary/stream interceptors + Authorize
./connectrpc .../connectrpc → connectrpcsec ConnectRPC auth + authorize interceptors
./basic .../basic HTTP Basic extractor + authenticator
./bearer .../bearer Bearer extractor + TokenVerifier authenticator
./password .../password BCrypt + Argon2id hashers (NeedsRehash)
./jwt .../jwt → jwtsec JWT signer/verifier + JWKS + key rotation
./session .../session Stateless AES-256-GCM cookie sessions + CSRF
./oauth2 .../oauth2 OAuth2 authorization server
./oauth2/storage/memory .../oauth2/storage/memory In-memory oauth2.Storage — package of oauth2
./oauth2/store/sql .../oauth2/store/sql Production storage on database/sql (PG/MySQL/SQLite)
./oauth2/store/redis .../oauth2/store/redis Production storage on Redis (Lua atomicity)
./oidc/client .../oidc/client → oidcclient OpenID Connect Relying Party (code flow + PKCE)
./oidc/client/storage/memory .../oidc/client/storage/memory In-memory TransactionStore — package of oidc/client
./oidc/client/storetest .../oidc/client/storetest Store conformance suite — package of oidc/client
./oidc/client/store/sql .../oidc/client/store/sql → sqlstore Transaction store on database/sql (PG/MySQL/SQLite)
./oidc/client/store/redis .../oidc/client/store/redis → redisstore Transaction store on Redis (Lua atomicity)
./oidc/provider .../oidc/provider → oidcprovider OpenID Provider on the oauth2 extension points
./saml/sp .../saml/sp → samlsp SAML 2.0 Service Provider (SP-initiated SSO)
./saml/sp/storage/memory .../saml/sp/storage/memory In-memory samlsp.Storage — package of saml/sp
./saml/sp/storetest .../saml/sp/storetest Storage conformance suite — package of saml/sp
./saml/sp/store/sql .../saml/sp/store/sql → sqlstore SAML storage on database/sql (PG/MySQL/SQLite)
./saml/sp/store/redis .../saml/sp/store/redis → redisstore SAML storage on Redis (single-key Lua scripts)
./examples .../examples Runnable demos: basic-http, bearer-jwt, grpc-bearer, connectrpc-bearer, session-web, oauth2, oidc-client, oidc-provider, saml-sp
./internal/integrations (private) Cross-module end-to-end tests
./internal/interop/oidcop (private, tests only) oidc/provider vs go-oidc + x/oauth2 as independent RP
./internal/interop/oidcrp (private, tests only) oidc/client against zitadel/oidc + opt-in Chrome test
./internal/interop/oidcstack (private, tests only) our RP ↔ our OP end to end, attacks included; real-backend store runs (CI job; opt-in locally)
./internal/interop/saml (private, tests only) saml/sp against crewjam's IdP, Keycloak (pinned + opt-in live), shared stores, e2e helper
./internal/interop/sqlstores (private, tests only) the three SQL stores on real servers (SQLite always; PostgreSQL, MySQL, MariaDB in the CI job, opt-in locally): racing Migrate, oauth2 conformance, MySQL upgrade statements

oauth2/storage/memory is not a standalone module — it is a sub-package of oauth2; likewise oidc/client/storage/memory and oidc/client/storetest belong to oidc/client, and saml/sp/storage/memory and saml/sp/storetest to saml/sp; outbound is a sub-package of the core (stdlib only, the core's only networking code: every server-side fetch of a configuration- or metadata-supplied URL MUST go through outbound.NewClient). The other rows are independent modules (own go.mod).

The dependency direction is a hard rule (enforced by review, see MIGRATION.md): the core (.) must depend only on stdlib + go.opentelemetry.io/otel (+ testify in its own tests). It MUST NOT import gRPC, ConnectRPC, JOSE/JWT libs, OAuth2, Redis, SQL drivers, HTTP routers, or concrete loggers. Adapters depend on the core, never the reverse. The oauth2 module has no hard dependency on jwt — JWT access tokens are wired via an adapter (jwt depends on oauth2, not the other way). oidc/client depends on the core and go-jose only — neither oauth2 nor jwt; coreos/go-oidc and golang.org/x/oauth2 are not dependencies (see the evaluation in oidc/client/doc.go). oidc/provider depends on core + oauth2 + jwt only (go-jose stays behind jwt; TestDependencyPolicy enforces it); go-oidc and x/oauth2 are allowed only in the test-only internal/interop/oidcop module, as an independent relying party. The SAML XML-DSig stack (gosaml2, goxmldsig ≥ v1.6.1, etree) stays inside saml/sp; crewjam/saml, the SQL drivers (pgx, go-sql-driver/mysql, modernc sqlite), go-redis and miniredis are test-only dependencies of internal/interop/saml. When adding code, check the allowed-dependency list in MIGRATION.md before adding an import.

Every sub-module declares replace github.com/hyperscale-stack/security => ../ (=> ../../ for oidc/provider and internal/integrations, => ../../../ for the OAuth2 SQL/Redis stores and the interop modules, => ../../../../ for the OIDC client and SAML SP stores, which also replace oidc/client => ../../ or saml/sp => ../../) plus one per in-repo module of its graph, so local dev works without published versions; consumers ignore them, so a release commit sets the in-repo requirements to real versions (docs/releasing.md). New modules declare go 1.27.2: every go.mod and go.work carry the same go line, and a new module must be added to go.work (make check-modules enforces both).

Core architecture

A request flows through this pipeline (all of it transport-agnostic — HTTP and gRPC are just adapters):

Carrier ──> Extractor ──> Authentication ──> Manager/Authenticator ──> Engine
                                                                         │
                                          AccessDecisionManager/Voter <──┘
  • Carrier — abstracts a transport message (HTTP request, gRPC metadata) 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.
  • Authentication — immutable snapshot of a security context (Principal, Credentials, Authorities, IsAuthenticated, Name). Every state change produces a new value; implementations MUST NOT be mutated. Safe for concurrent reads with no synchronization.
  • Authenticator — two-step: Supports() (cheap type switch, no I/O) then Authenticate() returns a new authenticated value or a wrapped sentinel error.
  • Manager — chains authenticators, first-success-wins in registration order; joins errors; returns ErrUnsupportedCredential when none support the credential.
  • Engine — top-level entry point: runs extractors, hands the result to the Manager, returns a context enriched via WithAuthentication.
  • Authorization — Voter returns Decision (Grant/Deny/Abstain) over a set of Attributes; AccessDecisionManager combines votes with an 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_<name> authorities and scopes only as one scope:<name> 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 unless one explicitly grants. The HTTP middleware rejects with 401 unless 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. 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 unexported key — WithAuthentication / FromContext (returns Anonymous() when absent).
  • OTel spans live directly in each module — there is intentionally no EventSink abstraction and no separate otel/ module. The core uses scope github.com/hyperscale-stack/security; each instrumented module (httpsec, grpcsec, connectrpcsec, jwtsec, session, samlsp) uses its own. See docs/observability.md for the span catalog.
  • Credential checks spend the same work. basic.Authenticator runs exactly one hasher.Verify per attempt — against a dummy hash made by the configured hasher for unknown users, loader failures and accounts without a password — reads the account state after it, and caps concurrent verifications (WithMaxConcurrentVerifications, ErrUnavailable when the caller gives up). password hashers check a stored hash's parameters (RFC 9106 §3.1 ranges, operator limits) before any hashing.
  • Sessions are sealed cookies: format v2 (AES-256-GCM under an HKDF key from ≥ 32 random bytes, associated data = version ‖ cookie name ‖ purpose), default name __Host-session, absolute lifetime CreatedAt + TTL never extended. NewManager panics on a configuration browsers would refuse.
  • Credentials never print: the basic, bearer and session authentications and session.Session print and log without their secrets (String, Format for every verb, LogValue); session.Session also has a redacting MarshalJSON.

OAuth2 server (oauth2/)

oauth2.NewServer(ServerConfig{...}) aggregates Profile, Storage, ClientStore, IssuerResolver, Grants, and ClientAuth, and exposes one http.Handler per RFC endpoint: AuthorizeHandler, TokenHandler, RevokeHandler, IntrospectHandler, MetadataHandler (endpoint paths configurable via ServerConfig.RoutePrefix).

  • Profile (2.0 / 2.0-BCP / 2.1-draft) is enforced at runtime on the grants — PKCE required and plain PKCE refused under BCP/2.1 (PKCE is required of non-confidential clients under every profile); legacy password and implicit flows refused outside Profile20.
  • Sub-packages: grant/ (authorization_code, client_credentials, refresh_token with rotation + reuse detection, opt-in legacy password), clientauth/ (client_secret_basic / _post / none), token/ (opaque + JWT generators), pkce/.
  • Fail-closed client policy: a client uses only the grant types (GrantTypes, checked at /token for every grant before Handle) and requests only the scopes (Scopes) it declares — an empty list allows nothing (oauth2.ClientAllowsGrantType, ErrNoGrantTypesDeclared, ErrNoScopesDeclared); client_credentials is confidential-only; refresh tokens go to clients declaring refresh_token. Every test client fixture must declare its grant types and scopes.
  • Access/refresh tokens and authz codes are stored hashed (HashToken).
  • Every authorization is bound to its issuer: every endpoint resolves it first (the IssuerResolver is the realm), codes and tokens record it, and the authorization_code / refresh_token grants refuse it elsewhere; /introspect answers another issuer's token inactive and /revoke leaves it alone. A client implementing IssuerBoundClient (DefaultClient.IssuerValues) is unknown at other issuers — same bytes as an unknown client_id.
  • Hardened endpoints: every request is bounded (ServerConfig.MaxRequestBytes, 64 KiB; state, nonce, client_id bounds); /authorize sets anti-framing / no-store / Referrer-Policy: same-origin headers, answers a POST with 303, validates redirect URIs (ValidateRedirectURI) and, with AuthorizeConfig.TrustedRedirect, redirects no error of an untrusted client before authentication. Every failed client authentication answers the same invalid_client ("client authentication failed", detail in OnError); a ClientStore error not carrying invalid_client is an outage (500/503), not an unknown client.
  • Extension points (ServerConfig.Extension, oauth2.Extension): the contract oidc/provider builds on — ValidateAuthorizeRequest after the core checks, CompleteAuthorization turning Consent.ExtensionResult into an ExtensionGrant (narrowing scope + opaque ExtensionData ≤ 4 KiB, copied onto every token, unchanged across refreshes), ExtendTokenResponse between minting and the single atomic write (typed id_token). Protocol refusals go through Consent.Refusal (oauth2.ErrLoginRequired, …); grant.Config.RefreshTokenScope gates refresh tokens on a scope (offline_access). oauth2 never imports OIDC/JOSE.
  • Storage implementations: oauth2/storage/memory (dev/tests), oauth2/store/sql, oauth2/store/redis. Every implementation must pass the shared oauth2/storetest conformance suite; store/sql's Migrate only creates tables — schema additions ship as ALTER statements in MIGRATION.md.

OpenID Provider (oidc/provider/)

oidcprovider.New(oauth2.ServerConfig{...}, oidcprovider.Config{Keys, Claims, Interaction}) builds the oauth2.Server with the provider as its Extension and owns its ConsentFunc; Provider.Paths(issuer) gives the mount path of every handler (AuthorizeHandler, UserInfoHandler, DiscoveryHandler, JWKSHandler, plus the server's own).

  • The application implements Interaction (login/consent UI; prompt=none goes to AuthorizeSilently, without a ResponseWriter), ClaimsSource (ErrUnknownSubject stops a grant) and KeySource (per-issuer KeyRing, validated on every use, RS256 required).
  • The provider enforces the protocol itself: request validation (OIDC Core §3.1.2), Request.Check on every approval (realm, prompt=login, max_age, designated subject, essential acr), offline_access consent (Core §11), ID Tokens at code exchange and refresh (Core §12.2), UserInfo with RFC 6750 errors.
  • The grant context (authentication event + released claims) travels in the core's ExtensionData — no storage of its own.
  • docs/oidc-provider.md is the exact feature matrix (no certification claimed); internal/interop/oidcop checks interop with go-oidc.

examples/oauth2/main.go is the canonical wiring example of the library; examples/oidc-provider the one for the OpenID Provider (login and consent pages, CSRF, sessions); examples/ also has basic-http, bearer-jwt, grpc-bearer, connectrpc-bearer, session-web, oidc-client and saml-sp demos.

OpenID Connect client (oidc/client/)

oidcclient.New(ctx, Config{...}) builds the Relying Party of one connection (issuer + client registration + redirect URI) after discovery; Begin/Complete run the Authorization Code flow with PKCE S256, state and nonce, and StartLogin/HandleCallback wrap them for net/http (binding cookie included). UserInfo fetches claims whose sub must match.

  • Invariants: the issuer comes from configuration only; every fetch goes through outbound; token and UserInfo requests follow no redirect, and discovery / JWK Set redirects never leave the origin requested (sameOriginRedirect); a login is bound to the user agent by a binding secret from which the nonce and PKCE verifier are derived (the store holds no secret) and which, with a REQUIRED server-held key (Config.TransactionKeys: the first MACs, all verify), keys the transaction's integrity MAC (integrityFor, verified in Complete before any stored field is used; every store persists Integrity); a transaction is consumed once, in the pinned order of TransactionStore.Consume; Callback.URL must be the connection's redirect URI — scheme, host, port, path and its query parameters (mix-up defense — redirect URIs are distinct per connection; HandleCallback takes the host from r.Host and the scheme from the redirect URI); nothing is returned before the ID Token is fully validated; every JSON document goes through the strict decoder (strictjson.go); errors and every fmt/slog rendering (format.go: Format, String, GoString, LogValue) never carry secrets, nor does encoding/json of Config, AuthRequest, Callback (MarshalJSON) — except Identity/Tokens, JSON-serialisable by design (persistence), which slog's JSON handler writes in full when nested in another value.
  • Every TransactionStore (storage/memory, store/sql, store/redis) must pass oidc/client/storetest.RunConformance.
  • Tests use the scriptable fake provider of fakeop_test.go; internal/interop/oidcrp runs the client against zitadel/oidc and a pinned corpus of real providers' discovery documents (testdata/metadata); its Chrome test runs with OIDC_BROWSER_TESTS=1, its live discovery check with OIDC_LIVE_DISCOVERY=1 (Internet, no credentials).
  • internal/interop/oidcstack runs the client against the provider over HTTP — flows, attacks, failures, concurrency — importing no third-party OIDC, OAuth 2.0 or JOSE library (TestDependencyPolicy; the client's own go-jose is part of the code under test); its real-backend store runs (OIDCCLIENT_POSTGRES_DSN, OIDCCLIENT_MYSQL_DSN, OIDCCLIENT_REDIS_ADDR, compose.yaml) are opt-in locally and run in CI through make test-backends. TestDocumentedTestsExist fails when docs/oidc-{conformance,client,provider}.md cite a test that does not exist, or when a page claims a certification.
  • Opt-in, never in CI: make e2e-oidc (e2e/oidc/*.tales, Tales v0.1.0-rc.23 + Chrome, against examples/oidc-provider and examples/oidc-client), make e2e-oidc-live (LIVE_OIDC_*), and make conformance (the OpenID Foundation suite, OIDF_SUITE_DIR; internal/interop/oidcstack/conformance: the expectations in its testdata must be updated with a rationale when a result changes). docs/oidc-conformance.md records their results and the release checklist.

SAML Service Provider (saml/sp/)

samlsp.New(Config{...}) takes a ConnectionResolver (each Connection has its own SP entity ID, ACS URL and trusted IdentityProvider), a TransactionStore, a ReplayCache and the REQUIRED TransactionKeys (1 to 4 server-held keys of at least 32 bytes; every stored transaction carries their HMAC, verified before any field is used). BeginLogin (HTTP-Redirect AuthnRequest + __Host-samlsp browser-binding cookie) and CompleteLogin (HTTP-POST ACS) are the flow; Metadata/ServeMetadata publish SP metadata; ParseIdentityProviderMetadata imports trusted IdP metadata, and FetchIdentityProviderMetadata/MetadataSource fetch it from a configured URL through outbound (pinned entity ID, bounded refresh, never triggered by response content).

  • Validation is layered: streaming lexical gate (xmlgate.go) → structural profile on the raw etree (precheck*.go) → transaction lookup: integrity MAC (integrity.go), browser binding, expiry → status and Response InResponseTo → atomic consumption (one verification per login) → gosaml2/goxmldsig verification (verify.go) → processing rules on the verified decoded values (postcheck.go) → replay mark. Never read identity data from the raw tree; never write XML-DSig/C14N code.
  • Strict by default: SHA-1 only via Connection.InsecureAllowSHA1; ambiguous shapes and encrypted assertions refused with typed errors. Every refusal matches ErrRejected (wraps security.ErrInvalidCredentials); store/resolver failures do not.
  • Storage implementations: saml/sp/storage/memory (dev/tests), saml/sp/store/sql, saml/sp/store/redis; all must pass saml/sp/storetest.
  • saml/sp/testdata/corpus pins 390 entries over 360 signed adversarial responses, stored uncompressed (review them as text diffs); after a deliberate change regenerate it with go test -run '^TestCorpusGenerate$' -count=1 . -args -corpus.update (in saml/sp): regeneration is byte-identical only with the toolchain recorded in the manifest (generatedWith); with another Go version every file changes (new RSA keys), so regenerate in a commit of its own. TestCorpusCoversEveryRule requires every refusal rule of the sources in some entry (or in its documented allowlist).
  • internal/interop/saml runs the SP against crewjam's IdP, replays the pinned Keycloak responses (testdata/keycloak-*), races instances on shared stores and tests metadata retrieval. Opt-in, with internal/interop/saml/compose.yaml: SAMLSP_KEYCLOAK_URL + SAMLSP_KEYCLOAK_ADMIN_PASSWORD (TestKeycloakLive; -args -keycloak.capture rewrites the 26.7.4 captures, and only -args -keycloak.fixture against Keycloak 26.0.7 rewrites the 26.0.7 fixture), SAMLSP_REDIS_ADDR, SAMLSP_POSTGRES_DSN, SAMLSP_MYSQL_DSN — CI runs the shared-store backends through make test-backends.
  • e2e/saml/*.tales (make e2e, e2e/saml/run.sh) drives examples/saml-sp in Chrome; its sites are served by TestE2EHelperProcess (skipped unless SAMLSP_E2E_SERVE is set). CI never runs it: re-run make e2e after any change to the example or to samlsp's cookie, login or ACS code. The integration guide is docs/saml-sp.md.

Tooling caveats

  • Mocks: make generate runs mockery v3 (pinned in tools/mockery, configured by .mockery.yaml). Every interface of the library packages gets an in-package mock_<interface>_test.go (Mock<Interface>, testify template, EXPECT() API). These files exist only in test binaries, are never committed (.gitignore: mock_*) and are regenerated by make generate, which make test/lint/vuln/tidy and CI run first. Use them for interface doubles in new tests; migrate a hand-written fake when you touch its test (no big-bang rewrite). A module whose packages get mocks keeps a mockery_test.go that imports testify/mock, so its go.mod/go.sum are the same with or without generated mocks (Dependabot and go mod tidy never generate them). Every module builds with GOWORK=off without them; only its tests need them, so the tests of a published module only compile after make generate in a repository checkout (a module zip has neither the mocks nor .mockery.yaml). Never hand-write a Mock<Interface> identifier or a mock_* file (git-ignored).
  • Tools: one module per tool under tools/ (golangci-lint, govulncheck, goveralls, mockery), outside go.work, so their dependencies never reach a library go.mod and each tool builds with exactly its own tested dependency set. make tools installs them into build/bin (checksum-verified by each tool's go.sum). Bump one with cd tools/<name> && GOWORK=off go get -tool <package>@<version>.
  • Lint: golangci-lint v2.14.0, default: none + ~30 explicitly-enabled linters including gosec, wrapcheck, errorlint, wsl_v5, forcetypeassert. gocyclo max complexity 18. Tests are excluded from lint (run.tests: false). All wrapped errors must keep %w.
  • Go 1.27.2 in every go.mod and in go.work (CI installs the version go.work names). Indentation: tabs in .go and Makefile, 4 spaces elsewhere, 2 spaces in YAML (see .editorconfig). All source files carry the MIT copyright header.