diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 026b5c7..c4d46ef 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,6 +3,3 @@ contact_links: - name: Report a security vulnerability url: https://github.com/ovander/backendkit/security/advisories/new about: Report privately; please do not open a public issue. - - name: Problem with the Socrate server itself - url: https://github.com/ovander/go-oauth2/issues - about: Issues in the identity provider belong in ovander/go-oauth2. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2b15178..4ea0b52 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,16 +4,21 @@ ## How it was tested - + -- [ ] `go build ./...`, `go vet ./...`, `go test -race -count=1 ./...` pass -- [ ] `golangci-lint run ./...` reports no issue -- [ ] `go mod tidy` leaves `go.sum` unchanged +- [ ] `go mod tidy && git diff --exit-code go.sum` leaves `go.sum` unchanged +- [ ] `go build ./...` passes +- [ ] `go vet ./...` passes +- [ ] `go test -race -count=1 -timeout=120s ./...` passes +- [ ] `golangci-lint run ./...` (v2.14.0) reports no issue +- [ ] `govulncheck ./...` reports no vulnerability - [ ] A line is added under `## [Unreleased]` in `CHANGELOG.md` ## Compatibility - -- Exported API: -- Behaviour change for existing callers: -- Breaking change: + + +- Exported-API change: +- Behaviour change for existing callers: +- Breaking change: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc99a6..78a9942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,13 @@ # Changelog -All notable changes to backendkit are documented here. The format is based on -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows -[Semantic Versioning](https://semver.org/spec/v2.0.0.html). +All notable changes to backendkit are documented here. Format: +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow +[Semantic Versioning](https://semver.org/). ## [Unreleased] -Policy enforcement for applications (Socrate plan A4, part 2), and a patched -build toolchain. Both purely additive for consumers. +Policy enforcement for applications against Socrate's central policy decision +point, and a patched build toolchain. Both purely additive for consumers. ### Added @@ -39,6 +39,9 @@ build toolchain. Both purely additive for consumers. ### Changed +- **Documentation uplift:** the README, `docs/CLIENT-INTEGRATION.md` (new BFF and `pep` + sections; a BFF is now the recommended path for browser apps), `SECURITY.md`, `CONTRIBUTING.md` + and the GitHub templates follow the Socrate suite's documentation standard. - **Build toolchain: Go 1.26.8 → Go 1.27.1** (`toolchain` directive and CI). The `go 1.25.0` minimum is unchanged: consumers are unaffected, and the GODEBUG defaults this module's own tests run with stay those of Go 1.25. @@ -53,14 +56,15 @@ build toolchain. Both purely additive for consumers. ### Fixed - README and `docs/CLIENT-INTEGRATION.md` linked Socrate to a repository that does not exist; - they now point to `ovander/go-oauth2`. The README's contributing rule on cross-package imports - now matches the code (`bff`/`pep` → `socrate`, `aigateway` → `ailang`). + they now name `ovander/go-oauth2` in plain text, as it is not public yet. The README's + contributing rule on cross-package imports now matches the code (`bff`/`pep` → `socrate`, + `aigateway` → `ailang`). ### Removed -- The internal review documents (`CTO-ARCHITECTURE-REVIEW.md`, `FRAMEWORK-EVOLUTION.md`, - `SECURITY-ARCHITECTURE.md`, `SECURITY-AUDIT.md`) left the public tree. The fixes they led to - remain listed below with their finding IDs. +- The internal review documents (the architecture review, the framework-evolution notes, the + security architecture review and the security audit) left the public tree. The fixes they led + to remain listed below with their finding IDs. ## [1.13.0] - 2026-09-04 @@ -81,9 +85,8 @@ service lands on one dashboard. Purely additive. ## [1.12.0] - 2026-09-03 -Shared-gateway hardening from the Socrate suite pass-3 audit -(`CR-socrate-suite-security-pass3.md`, `go-oauth2` repo). Additive except -for one behaviour change called out below. +Shared-gateway hardening from the third-pass security review of the Socrate +suite. Additive except for one behaviour change called out below. > **Behaviour change (P3-12):** `Gateway.ProxyWithSession` no longer deletes > the session on *every* refresh error. Only a refresh the authorization @@ -179,15 +182,15 @@ for one behaviour change called out below. called `ConstantTimeCompare` directly, so a session that somehow lost its CSRF value (`csrf == ""`) matched an empty request token, silently disabling CSRF protection for that session. `MatchCSRF` now always returns - `false` when the stored value is empty. Addresses **P2-6** - (`CR-socrate-suite-security-pass2.md`, upstream `go-oauth2` repo). + `false` when the stored value is empty. Addresses **P2-6** (second-pass + security review of the Socrate suite). - **bff: `Gateway`'s zero value is now fail-closed.** `Gateway.AuthEnabled` defaulted to `false`, so a bare `&Gateway{...}` struct literal — no field set — was a fully-open pass-through, contradicting this package's documented "fail-closed by default" behaviour. The field is renamed and inverted to **`DisableAuth`**, so the zero value now means "auth enforced." - Addresses **P2-7** (`CR-socrate-suite-security-pass2.md`). + Addresses **P2-7** (second-pass security review). - **bff: coalesce concurrent token refreshes per session.** Concurrent `EnsureFresh` calls near token expiry could each independently spend the @@ -195,7 +198,7 @@ for one behaviour change called out below. rest tore down the session. `EnsureFresh` now coalesces concurrent calls per session ID via `singleflight.Group` (mirroring the `jwtauth` H-1 JWKS fix), with every waiter re-checking token validity before spending a - refresh. Addresses **P2-8** (`CR-socrate-suite-security-pass2.md`). + refresh. Addresses **P2-8** (second-pass security review). ### Migration @@ -244,13 +247,13 @@ gw := &bff.Gateway{Store: store, Cookie: cookie, Refresher: r} inside the window returns key-not-found without a network call, and (3) backed by a short negative cache (default 30s; `WithNegativeCacheTTL`) for recently-seen unknown kids. A legitimately rotated key still resolves: the first miss after the - cooldown triggers exactly one refetch. Addresses **H-1** (`SECURITY-AUDIT.md`). + cooldown triggers exactly one refetch. Addresses **H-1** (internal security audit). - **jwtauth: require `exp` and add clock-skew leeway.** The parser now sets `jwt.WithExpirationRequired()`, so a token minted without an `exp` claim (which would otherwise never expire) is rejected, plus `jwt.WithLeeway` (default 60s; `WithLeeway`) for time-based claim validation. Addresses **M-2** - (`SECURITY-AUDIT.md`). + (internal security audit). - **socrate: complete path-segment escaping (corrects the F-7 ledger).** v1.9.0 escaped only `client.go`; the remaining admin/monitoring/alerts/reports methods @@ -259,7 +262,7 @@ gw := &bff.Gateway{Store: store, Cookie: cookie, Refresher: r} `alerts.go` and `reports.go` (user/app/superadmin/blocked-ip/ip-reputation/log/ alert-rule/report IDs). Internal server-resolved values (e.g. the app ID) are intentionally left unescaped, as in `client.go`. Addresses **M-1** and corrects - the previously overstated **F-7** "Fixed" claim (`SECURITY-AUDIT.md`). + the previously overstated **F-7** "Fixed" claim (internal security audit). ## [1.9.0] - 2026-06-20 @@ -269,7 +272,7 @@ gw := &bff.Gateway{Store: store, Cookie: cookie, Refresher: r} warning at construction when the issuer is empty, so a service running without `iss` enforcement is visible at startup instead of silently fail-open. No change to token validation; making issuer mandatory remains a v2.0 default-flip. - Addresses **F-5** (`SECURITY-AUDIT.md`). + Addresses **F-5** (internal security audit). ([#30](https://github.com/ovander/backendkit/issues/30)) - **apierror: redact internal message/details on 5xx responses.** `WriteJSON` now @@ -277,8 +280,8 @@ gw := &bff.Gateway{Store: store, Cookie: cookie, Refresher: r} for any 5xx response, so internal detail (e.g. `apierror.Internal(err.Error())`) can no longer leak to clients. **4xx responses are unchanged.** The full error is still available server-side via `Error()` for logging; the struct doc was - corrected. Addresses **F-17 / INV-9** (`SECURITY-AUDIT.md`, - `SECURITY-ARCHITECTURE.md`). + corrected. Addresses **F-17 / INV-9** (internal security audit + and architecture review). **Behaviour change:** 5xx response bodies no longer echo the supplied message. ([#28](https://github.com/ovander/backendkit/issues/28)) @@ -287,7 +290,7 @@ gw := &bff.Gateway{Store: store, Cookie: cookie, Refresher: r} timing/row-count/caller). GORM hands the logger SQL with bound parameter values already interpolated — which can contain PII or secrets — so production loggers should enable it. Opt-in; default behaviour unchanged. Addresses **F-9 / INV-10** - (`SECURITY-AUDIT.md`, `SECURITY-ARCHITECTURE.md`). + (internal security audit and architecture review). ([#26](https://github.com/ovander/backendkit/issues/26)) - **jwtauth / socrate / aigateway: bound upstream response reads.** All reads of @@ -295,7 +298,7 @@ gw := &bff.Gateway{Store: store, Cookie: cookie, Refresher: r} Socrate and AI-provider responses at 10 MiB — so a compromised/MITM or oversized upstream cannot exhaust memory. `socrate.readBody` returns an explicit error when the cap is exceeded. Normal-size responses are unaffected. Addresses - **F-8 / INV-12** (`SECURITY-AUDIT.md`, `SECURITY-ARCHITECTURE.md`). + **F-8 / INV-12** (internal security audit and architecture review). ([#24](https://github.com/ovander/backendkit/issues/24)) - **socrate: path-escape `userID` in request URLs.** `socrate.Client` now wraps the @@ -303,7 +306,7 @@ gw := &bff.Gateway{Store: store, Cookie: cookie, Refresher: r} it (`GetUser`, `UpdateUserRole`, `DeleteUser`, `ResendVerification`, `ForcePasswordReset`, `GetUserAsService`), so an ID containing `/`, `?`, `#`, or `..` can no longer rewrite the target route. Addresses **F-7 / INV-11** - (`SECURITY-AUDIT.md`, `SECURITY-ARCHITECTURE.md`). + (internal security audit and architecture review). ([#22](https://github.com/ovander/backendkit/issues/22)) ## [1.8.0] - 2026-06-20 @@ -315,7 +318,7 @@ gw := &bff.Gateway{Store: store, Cookie: cookie, Refresher: r} exponent (odd, > 1, within `int` range) instead of silently truncating it, so a JWKS serving an undersized or malformed key is no longer trusted. Backward compatible for real deployments (Socrate/RS256 use ≥2048-bit keys). Addresses - **F-10 / INV-13** (`SECURITY-AUDIT.md`, `SECURITY-ARCHITECTURE.md`). + **F-10 / INV-13** (internal security audit and architecture review). ([#18](https://github.com/ovander/backendkit/issues/18)) - **deps: bump `golang-jwt/jwt/v5` `v5.2.1` → `v5.2.2`.** Clears GO-2025-3553 @@ -343,7 +346,7 @@ gw := &bff.Gateway{Store: store, Cookie: cookie, Refresher: r} `token_version` (logout / password-change / admin revocation) or a `jti` denylist — checks that local signature validation alone cannot. Opt-in: with none configured, a token stays valid until `exp` as before. Addresses **F-2 / INV-3** - (`SECURITY-AUDIT.md`, `SECURITY-ARCHITECTURE.md`). + (internal security audit and architecture review). ([#16](https://github.com/ovander/backendkit/issues/16)) - **httpware: `RequireTenant` middleware.** A plain @@ -352,7 +355,7 @@ gw := &bff.Gateway{Store: store, Cookie: cookie, Refresher: r} tenant-scoped handlers can never run against the nil tenant. Opt-in; mount it after the auth middleware on tenant-scoped route groups. Rejections are logged through the request-scoped logger. Addresses **F-3 / INV-6** - (`SECURITY-AUDIT.md`, `SECURITY-ARCHITECTURE.md`). + (internal security audit and architecture review). ([#14](https://github.com/ovander/backendkit/issues/14)) - **jwtauth: opt-in JWT audience (`aud`) validation.** New `jwtauth.Option` @@ -361,7 +364,7 @@ gw := &bff.Gateway{Store: store, Cookie: cookie, Refresher: r} expected audience (typically the service's OAuth `client_id`), closing the cross-app token-replay exposure where one Socrate-issued token was valid at every service sharing the same issuer and JWKS. - Resolves **F-1 / INV-2** (`SECURITY-AUDIT.md`, `SECURITY-ARCHITECTURE.md`). + Resolves **F-1 / INV-2** (internal security audit and architecture review). ([#6](https://github.com/ovander/backendkit/issues/6)) ### Migration @@ -388,9 +391,11 @@ are rejected. Confirm your Socrate server populates `aud` before enabling it in production. Making audience validation required-by-default is deferred to a future major (v2.0) and tracked separately. -### Notes - -- `govulncheck` is part of the required quality gates but could not be executed in - the CI sandbox for this change because `https://vuln.go.dev` is blocked by the - environment's network policy. All other gates (`go fmt`, `go vet`, - `golangci-lint`, `go test`, `go test -race`) pass. +[Unreleased]: https://github.com/ovander/backendkit/compare/v1.13.0...HEAD +[1.13.0]: https://github.com/ovander/backendkit/compare/v1.12.0...v1.13.0 +[1.12.0]: https://github.com/ovander/backendkit/compare/v1.11.1...v1.12.0 +[1.11.1]: https://github.com/ovander/backendkit/compare/v1.11.0...v1.11.1 +[1.11.0]: https://github.com/ovander/backendkit/compare/v1.10.0...v1.11.0 +[1.10.0]: https://github.com/ovander/backendkit/compare/v1.9.0...v1.10.0 +[1.9.0]: https://github.com/ovander/backendkit/compare/v1.8.0...v1.9.0 +[1.8.0]: https://github.com/ovander/backendkit/compare/v1.7.0...v1.8.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e16b5fa..88a96a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,9 @@ # Contributing to backendkit -Thank you for your interest. backendkit is the shared Go library of the Socrate suite: it lets a -Go service authenticate users against the Socrate OAuth 2.1 / OIDC provider -([`ovander/go-oauth2`](https://github.com/ovander/go-oauth2)), run a Backend-for-Frontend, and -enforce Socrate's policies. Contributions are accepted under the project's licence, +Thank you for your interest. backendkit is part of the Socrate suite: it is the shared Go library +that lets a service validate tokens from Socrate, the suite's OAuth 2.1 / OpenID Connect server +(`ovander/go-oauth2`, not public yet), run a Backend-for-Frontend, and enforce Socrate's central +policy decisions. Contributions are accepted under the project's licence, [Apache-2.0](LICENSE). ## Development setup @@ -26,6 +26,8 @@ go test ./... discussing it first. - Every exported symbol has a doc comment that begins with its name. Runnable examples go in `example_test.go`; they appear on pkg.go.dev. +- A new package gets a package doc comment, a row in the README package tables and a section in + the README package reference. - Security-relevant behaviour fails closed by default (for example, `bff.Gateway` answers 401 without a valid session instead of passing the request through). An opt-out must be an explicit, documented option. @@ -46,16 +48,19 @@ govulncheck ./... ``` - Tests sit next to the code (`*_test.go`), table-driven. -- A bug fix comes with a test that fails without it. +- Do not weaken a check to get green: no skipped or deleted tests, and no `//nolint` or `t.Skip` + without a one-line reason. ## Pull requests -1. Branch from `main` (`feat/…`, `fix/…`, `chore/…`, `docs/…`). +1. Branch from `main` (`feat/…`, `fix/…`, `chore/…`, `ci/…`, `docs/…`). Keep one change per + pull request. 2. Commit with [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `chore:`, `docs:`, `ci:`, `test:`). -3. Add a line under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). -4. Open the PR with the template filled in, including any change to the exported API. -5. CI must be green. The maintainer reviews and merges. +3. A bug fix comes with a test that fails without it. +4. Add a line under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). +5. Open the PR with the template filled in, including any change to the exported API. +6. CI must be green. The maintainer reviews and merges. ## Releases diff --git a/README.md b/README.md index 60e01db..d6a3db6 100644 --- a/README.md +++ b/README.md @@ -2,58 +2,128 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/ovander/backendkit.svg)](https://pkg.go.dev/github.com/ovander/backendkit) [![CI](https://github.com/ovander/backendkit/actions/workflows/ci.yml/badge.svg)](https://github.com/ovander/backendkit/actions/workflows/ci.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/ovander/backendkit)](https://goreportcard.com/report/github.com/ovander/backendkit) -Shared Go library for backend services that use [Socrate](https://github.com/ovander/go-oauth2) as their OAuth2/OIDC provider. +> The shared Go library for services and Backend-for-Frontends that sign users in with Socrate. + +backendkit is a single Go module of small packages for teams building on Socrate, the suite's +OAuth 2.1 / OpenID Connect server (`ovander/go-oauth2`, not public yet). It validates Socrate +access tokens (`jwtauth`), runs a Backend-for-Frontend so a browser app never holds a token +(`bff`), enforces Socrate's central policy decisions in your handlers (`pep`), calls the Socrate +OAuth and admin APIs (`socrate`), and provides the service plumbing around them: middleware, +errors, context helpers, plan gating, pagination, build info and AI provider wrappers. It is for +Go developers writing an API or a BFF in the Socrate suite. --- -## Contents +## Table of contents -- [Why backendkit?](#why-backendkit) +- [Why backendkit](#why-backendkit) +- [Packages](#packages) + - [Which package do I need?](#which-package-do-i-need) - [Requirements](#requirements) - [Installation](#installation) -- [Environment variables](#environment-variables) - [Quick start](#quick-start) -- [Packages](#packages) · [Which package do I need?](#which-package-do-i-need) -- [Architecture overview](#architecture-overview) -- [Full integration example](#full-integration-example) -- [Package reference](#package-reference) - — [apierror](#apierror) · [ctxutil](#ctxutil) · [httpware](#httpware) · [gormlogger](#gormlogger) · [socrate](#socrate) · [jwtauth](#jwtauth) · [bff](#bff) · [pep](#pep) · [tiering](#tiering) · [aigateway](#aigateway) · [ailang](#ailang) · [ainarration](#ainarration) · [pagination](#pagination) · [buildinfo](#buildinfo) +- [Architecture](#architecture) +- [Package reference](#package-reference) — + [apierror](#apierror) · [ctxutil](#ctxutil) · [httpware](#httpware) · + [gormlogger](#gormlogger) · [socrate](#socrate) · [jwtauth](#jwtauth) · [bff](#bff) · + [pep](#pep) · [tiering](#tiering) · [aigateway](#aigateway) · [ailang](#ailang) · + [ainarration](#ainarration) · [pagination](#pagination) · [buildinfo](#buildinfo) +- [Environment variables](#environment-variables) - [Testing](#testing) - [Troubleshooting](#troubleshooting) -- [Production usage](#production-usage) +- [Used by](#used-by) - [Versioning](#versioning) -- [Contributing](#contributing) -- [Security](#security) -- [License](#license) +- [Status](#status) + +--- + +## Why backendkit + +Every service that trusts Socrate has to solve the same problems: validate RS256 tokens against a +rotating JWKS, keep tokens out of the browser, ask the central policy decision point before an +action, carry the user's claims through the request, and return consistent errors. Solved once +per service, this code is copied and drifts, and security fixes land in one place but not the +others. + +backendkit puts that foundation in one versioned dependency: + +- **Token validation in one call.** `jwtauth.New(...)` validates RS256 tokens, caches the JWKS, + and puts every Socrate claim in the request context. +- **No tokens in the browser.** `bff` is the runtime of a Backend-for-Frontend: server-side + sessions, `__Host-` cookies, CSRF checks, PKCE, and a fail-closed session→bearer proxy with + coalesced token refresh. The Socrate admin and monitoring consoles run on it. +- **Central policy, enforced locally.** `pep` asks Socrate's policy decision point about each + action and follows the mode Socrate reports (`off`, `shadow`, `enforce`), so one switch in + Socrate moves every application at once. +- **A typed Socrate client.** `socrate.Client` covers the token flows, user management, service + accounts, magic links, security monitoring and policy decisions. +- **Service plumbing.** Request IDs, structured logrus logging, Prometheus RED metrics, rate + limiting, plan gates backed by your database, and GORM slow-query logging. +- **Loosely coupled packages.** Import only what you need. Every package may use the shared + `ctxutil` and `apierror` primitives; beyond those, the only intra-module imports are + `bff` → `socrate`, `pep` → `socrate` and `aigateway` → `ailang`. --- -## Why backendkit? +## Packages + +| Package | Purpose | +|---------|---------| +| [`apierror`](#apierror) | Structured HTTP error types (`AppError`, constructor functions) | +| [`ctxutil`](#ctxutil) | Typed context keys for Socrate claims (tenant, user, role, plan, logger, request-id) | +| [`httpware`](#httpware) | Chi-compatible middlewares: RequestID, Logger, SecurityHeaders, BodyLimit, Recover, Timeout, RateLimiter, RequireTenant, RBAC, Metrics (Prometheus RED) | +| [`gormlogger`](#gormlogger) | GORM → logrus bridge with slow-query detection | +| [`jwtauth`](#jwtauth) | JWT RS256 validation middleware with JWKS cache and stale-key fallback | +| [`bff`](#bff) | Backend-for-Frontend runtime: server-side sessions, `__Host-` cookies, CSRF, PKCE, login binding and a fail-closed session→bearer proxy with coalesced token refresh | +| [`socrate`](#socrate) | Socrate API client: token flows, user CRUD, service-account token, magic links, invites, app and superadmin management, security monitoring, dashboard, audit logs, policy decisions | +| [`tiering`](#tiering) | Plan registry, tier gate middleware, feature policy model and service | +| [`pep`](#pep) | Policy enforcement point: asks Socrate's policy decision point about each action and honours the central `POLICY_MODE` (off / shadow / enforce) and obligations | +| [`aigateway`](#aigateway) | AI provider client (OpenAI and Claude; Ollama through `NewAIClient`), `ExtractJSON` / `ExtractJSONInto` | +| [`ailang`](#ailang) | Language guard for AI output: every response is in the requested locale (fr/en), with retry and translation fallback | +| [`ainarration`](#ainarration) | Generic LRU+TTL narration cache and `CacheKey` helper | +| [`pagination`](#pagination) | Query-param parsing and `PagedResponse` | +| [`buildinfo`](#buildinfo) | Build-time version metadata (`-ldflags`) and a `/version` HTTP handler | -Building a new Socrate-backed service means solving the same problems every time: validating RS256 JWTs from a JWKS endpoint, propagating tenant/user/plan claims through context, enforcing plan-based feature gates, wiring a structured middleware stack, and normalising AI provider calls. Without a shared library, this logic gets copy-pasted and diverges. +`go get` pulls the whole module, but importing one package brings in only what it builds on: the +shared `ctxutil` and `apierror` primitives, plus `socrate` for the two packages that exist to talk +to Socrate (`bff`, `pep`) and `ailang` for `aigateway`. -backendkit packages that foundation into a single, versioned dependency so every service starts from the same production-grade baseline: +### Which package do I need? -- **Zero boilerplate auth** — one `jwtauth.New(...)` call validates JWTs, caches JWKS keys, and injects all Socrate claims into the request context. -- **Consistent observability** — structured logrus logging, request IDs, and GORM slow-query detection are wired in from day one. -- **Plan-based access control** — `tiering` gives you a plan hierarchy, HTTP middleware gates, and per-feature policy rules backed by Postgres. -- **Socrate API client** — a single `socrate.Client` covers user CRUD, service-account token management, magic-link flows, and invite emails. -- **Decoupled packages** — import only what you need; there are no forced transitive dependencies between packages except the shared primitives (`ctxutil`, `apierror`). +| I want to… | Use | +|------------|-----| +| Validate incoming Socrate JWTs and populate the request context | [`jwtauth`](#jwtauth) | +| Serve a browser SPA without ever giving it OAuth tokens (BFF) | [`bff`](#bff) + [`socrate`](#socrate) | +| Authorise actions with Socrate's central, declarative policy (RBAC + ABAC, object-level) | [`pep`](#pep) | +| Read the tenant / user / role / plan of the current request | [`ctxutil`](#ctxutil) | +| Add request IDs, structured logging, panic recovery, timeouts, body limits, security headers | [`httpware`](#httpware) | +| Rate-limit per tenant | [`httpware.RateLimiter`](#httpware) | +| Expose Prometheus RED metrics keyed by route pattern (same metric scheme as Socrate) | [`httpware.Metrics`](#httpware) + `httpware.MetricsHandler` | +| Guarantee a tenant on tenant-scoped routes | [`httpware.RequireTenant`](#httpware) | +| Gate routes by role/permission | [`httpware.RBAC`](#httpware) | +| Gate routes or features by commercial plan | [`tiering`](#tiering) | +| Return consistent JSON errors | [`apierror`](#apierror) | +| Call Socrate to manage users, apps, tokens, or security | [`socrate`](#socrate) | +| Call OpenAI or Claude through one interface | [`aigateway`](#aigateway) | +| Guarantee AI output is in the user's language | [`ailang`](#ailang) | +| Cache AI results to cut latency and cost | [`ainarration`](#ainarration) | +| Parse `?page`/`?per_page` and return paged lists | [`pagination`](#pagination) | +| Log GORM queries through logrus / flag slow queries | [`gormlogger`](#gormlogger) | +| Expose build/version info on a `/version` endpoint | [`buildinfo`](#buildinfo) | --- ## Requirements -- **Go 1.25** or later to import the module. For building/releasing backendkit - itself, use **Go 1.27.1** (pinned via the `toolchain` directive in `go.mod`, - and checked against CI's Go on every run) so the binary picks up the latest Go - standard-library security fixes; run `govulncheck ./...` to verify. Go 1.25 - itself is out of support since Go 1.27's release — consumers should build with - a supported Go even though the module still accepts 1.25. -- **Socrate** — backendkit is not a generic OAuth2 toolkit. It is designed specifically for services that use Socrate as their identity provider. Without a running Socrate instance, `jwtauth`, `socrate`, and `ctxutil` will not function correctly. -- A PostgreSQL database is required if you use `tiering.PolicyService` for persistent feature policies. +- **Go 1.25 or later** to import the module (the `go` line in `go.mod`). Build your service with + a Go release that still receives security fixes. backendkit itself is built and tested with + Go 1.27.1, pinned by the `toolchain` line. +- **A Socrate server** for the packages that talk to it: `jwtauth`, `socrate`, `bff` and `pep`. + They are written for Socrate's API and claims, not as a generic OAuth toolkit. The other + packages, `ctxutil` included, are plain Go helpers and work without Socrate. +- **A database** only if you back `tiering.PolicyService` with one, through your own + `tiering.PolicyRepository`. --- @@ -74,22 +144,6 @@ require github.com/ovander/backendkit v1.13.0 --- -## Environment variables - -backendkit **reads no environment variables itself** — you pass configuration explicitly to each constructor. The variables below are the conventions used throughout this README's examples; name them however you like in your own service. - -| Variable | Consumed by | Purpose | -|----------|-------------|---------| -| `SOCRATE_JWKS_URL` | `jwtauth.New` | JWKS endpoint used to validate RS256 signatures | -| `SOCRATE_ISSUER` | `jwtauth.New` | Expected `iss` claim — optional; enforced only when non-empty | -| `SOCRATE_BASE_URL` | `socrate.NewClient` | Socrate OAuth port base URL (e.g. `https://auth.example.com`) | -| `SOCRATE_CLIENT_ID` | `socrate.NewClient` | OAuth client ID | -| `SOCRATE_CLIENT_SECRET` | `socrate.NewClient` | Client secret — required for service-account calls, `RevokeToken`, `IntrospectToken` | -| `SOCRATE_APP_ID` | `socrate.NewClient` | Pre-resolved numeric app ID — **required** for every service-account method | -| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | `aigateway.New` | Provider API key for the configured provider | - ---- - ## Quick start The smallest working setup: JWT validation and a single protected route. @@ -132,92 +186,11 @@ func main() { } ``` ---- +### Full middleware stack -## Packages - -| Package | Purpose | -|---------|---------| -| [`apierror`](#apierror) | Structured HTTP error types (`AppError`, constructor functions) | -| [`ctxutil`](#ctxutil) | Typed context keys for Socrate claims (tenant, user, role, plan, logger, request-id) | -| [`httpware`](#httpware) | Chi-compatible middlewares: RequestID, Logger, SecurityHeaders, BodyLimit, Recover, Timeout, RateLimiter, RequireTenant, RBAC, Metrics (Prometheus RED) | -| [`gormlogger`](#gormlogger) | GORM → logrus bridge with slow-query detection | -| [`jwtauth`](#jwtauth) | JWT RS256 validation middleware with JWKS cache and stale-key fallback | -| [`bff`](#bff) | Backend-for-Frontend runtime: server-side sessions, `__Host-` cookies, CSRF, PKCE, login binding and a fail-closed session→bearer proxy with coalesced token refresh | -| [`socrate`](#socrate) | Full Socrate API client: user CRUD, service-account token, magic links, invite, app & superadmin management, security monitoring, dashboard, audit logs, token introspection/revocation | -| [`tiering`](#tiering) | Plan registry, tier gate middleware, feature policy model and service | -| [`pep`](#pep) | Policy enforcement point: asks Socrate's policy decision point about each action and honours the central `POLICY_MODE` (off / shadow / enforce) and obligations | -| [`aigateway`](#aigateway) | Multi-provider AI client (OpenAI + Claude), `ExtractJSON`/`ExtractJSONInto` | -| [`ailang`](#ailang) | Language guard for AI output: every response is in the requested locale (fr/en), with retry and translation fallback | -| [`ainarration`](#ainarration) | Generic LRU+TTL narration cache and `CacheKey` helper | -| [`pagination`](#pagination) | Query-param parsing and `PagedResponse` | -| [`buildinfo`](#buildinfo) | Build-time version metadata (`-ldflags`) and a `/version` HTTP handler | - -Packages are independent — `go get` pulls the whole module, but importing one package drags in only what it builds on: the shared `ctxutil` and `apierror` primitives, plus `socrate` for the two packages that exist to talk to Socrate (`bff`, `pep`) and `ailang` for `aigateway`. - -### Which package do I need? - -| I want to… | Use | -|------------|-----| -| Validate incoming Socrate JWTs and populate the request context | [`jwtauth`](#jwtauth) | -| Serve a browser SPA without ever giving it OAuth tokens (BFF) | [`bff`](#bff) + [`socrate`](#socrate) | -| Read the tenant / user / role / plan of the current request | [`ctxutil`](#ctxutil) | -| Add request IDs, structured logging, panic recovery, timeouts, body limits, security headers | [`httpware`](#httpware) | -| Rate-limit per tenant | [`httpware.RateLimiter`](#httpware) | -| Expose Prometheus RED metrics keyed by route pattern (same dashboard as Socrate) | [`httpware.Metrics`](#httpware) + `httpware.MetricsHandler` | -| Guarantee a tenant on tenant-scoped routes | [`httpware.RequireTenant`](#httpware) | -| Gate routes by role/permission | [`httpware.RBAC`](#httpware) | -| Gate routes or features by commercial plan | [`tiering`](#tiering) | -| Authorise actions with Socrate's central, declarative policy (RBAC + ABAC, object-level) | [`pep`](#pep) | -| Return consistent JSON errors | [`apierror`](#apierror) | -| Call Socrate to manage users, apps, tokens, or security | [`socrate`](#socrate) | -| Call OpenAI or Claude through one interface | [`aigateway`](#aigateway) | -| Guarantee AI output is in the user's language | [`ailang`](#ailang) | -| Cache AI results to cut latency and cost | [`ainarration`](#ainarration) | -| Parse `?page`/`?per_page` and return paged lists | [`pagination`](#pagination) | -| Log GORM queries through logrus / flag slow queries | [`gormlogger`](#gormlogger) | -| Expose build/version info on a `/version` endpoint | [`buildinfo`](#buildinfo) | - ---- - -## Architecture overview - -A typical service wires the packages in three layers: - -``` -HTTP request - │ - ▼ -┌────────────────────────────────────────────┐ -│ httpware middleware stack (chi router) │ -│ RequestID → Logger → SecurityHeaders → │ -│ Recover → Timeout → jwtauth → RateLimiter│ -└───────────────────┬────────────────────────┘ - │ context carries: - │ tenant, user, role, plan, request-id, logger - ▼ -┌────────────────────────────────────────────┐ -│ Route handlers │ -│ • tiering.Gate.Require(plan) │ -│ • httpware.RBAC.Require(permission) │ -│ • socrate.Client (identity operations) │ -│ • aigateway.Client (AI calls) │ -│ • ainarration.NarrationCache (cache AI) │ -└───────────────────┬────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────┐ -│ Data layer │ -│ • GORM + gormlogger │ -│ • tiering.PolicyService (feature flags) │ -└────────────────────────────────────────────┘ -``` - ---- - -## Full integration example - -The following bootstraps a production chi router with the complete middleware stack, plan-based routing, and enterprise-only admin routes. +The following wires the complete middleware stack, plan-based routing and enterprise-only admin +routes on a chi router. For a browser front end, put a [`bff`](#bff) in front of this API; to +authorise actions centrally, add [`pep`](#pep). ```go package main @@ -243,6 +216,7 @@ func main() { os.Getenv("SOCRATE_JWKS_URL"), os.Getenv("SOCRATE_ISSUER"), log, + jwtauth.WithAudience(os.Getenv("SOCRATE_CLIENT_ID")), // reject tokens minted for other apps ) // 2. Per-tenant rate limiter — 20 rps sustained, burst of 40. @@ -283,13 +257,70 @@ func main() { } ``` +The [client integration guide](docs/CLIENT-INTEGRATION.md) walks through a complete integration +with Socrate: middleware wiring, the `socrate.Client` auth modes, login flows for browser and +non-browser clients, the BFF, policy enforcement and error handling. + +--- + +## Architecture + +A browser app reaches your API through a BFF; other clients send a bearer token directly. In the +API, the packages sit in three layers: + +``` + Browser (SPA) Non-browser client or service + │ opaque __Host- session cookie │ + ▼ │ +┌───────────────────────────────────────┐ │ +│ BFF (bff + socrate) │ │ +│ login: PKCE, state, LoginBinding │ │ +│ server-side session store │ │ +│ CSRF check on unsafe methods │ │ +│ ProxyWithSession: cookie → bearer │ │ +└───────────────────┬───────────────────┘ │ + │ Authorization: Bearer │ Authorization: Bearer + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Your API: httpware middleware stack (chi or net/http) │ +│ Metrics → RequestID → Logger → SecurityHeaders → Recover → │ +│ Timeout → jwtauth → RateLimiter │ +└──────────────────────────────────┬──────────────────────────────────┘ + │ context carries: sub, role, app roles, plan, + │ tenant, auth_time, amr, request id, logger + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Route handlers │ +│ • httpware.RBAC, tiering.Gate local role and plan gates │ +│ • pep.Enforcer Socrate policy decisions │ +│ • socrate.Client identity operations │ +│ • aigateway, ailang, ainarration AI calls, language, cache │ +└──────────────────────────────────┬──────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Data layer: GORM + gormlogger, tiering.PolicyService │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +Each layer talks to Socrate through its own package: + +- `jwtauth` fetches Socrate's JWKS to verify token signatures. +- `bff` exchanges codes and refreshes tokens at Socrate's token endpoint through `socrate.Client`. +- `pep` asks Socrate's policy decision point through `socrate.Client.Decide`. +- `socrate.Client` calls Socrate's OAuth and admin APIs for identity operations. + --- ## Package reference +Each section shows the common use of one package. The complete API, with runnable examples, is on +[pkg.go.dev](https://pkg.go.dev/github.com/ovander/backendkit). + ### apierror -Constructor functions for all common HTTP error shapes. Every constructor returns `*AppError` which implements `error` and serialises itself as JSON when written to an `http.ResponseWriter` via `WriteJSON`. The wire shape is wrapped in an `error` envelope: +Constructor functions for all common HTTP error shapes. Every constructor returns `*AppError` +which implements `error` and serialises itself as JSON when written to an `http.ResponseWriter` +via `WriteJSON`. The wire shape is wrapped in an `error` envelope: ```json {"error": {"code": "not_found", "message": "user not found: 42"}} @@ -304,15 +335,18 @@ if errors.Is(err, gorm.ErrRecordNotFound) { apierror.Internal("database error").WriteJSON(w) ``` -> **5xx responses are redacted (since v1.9.0).** For server errors (status ≥ 500), -> `WriteJSON` replaces `message` with a generic status text and drops `details`, so -> internal detail (e.g. `apierror.Internal(err.Error())`) cannot leak to clients. -> The full message is still available server-side via `Error()` for logging, and -> 4xx responses are unchanged. Clients should key on `error.code` for 5xx. +> **5xx responses are redacted (since v1.9.0).** For server errors (status ≥ 500), `WriteJSON` +> replaces `message` with a generic status text and drops `details`, so internal detail (e.g. +> `apierror.Internal(err.Error())`) cannot leak to clients. The full message is still available +> server-side via `Error()` for logging, and 4xx responses are unchanged. Clients should key on +> `error.code` for 5xx. -Available constructors: `NotFound`, `BadRequest`, `Unauthorized`, `Forbidden`, `Conflict`, `ValidationError`, `TooManyRequests`, `Internal`, `BadGateway`, `ServiceUnavailable`. +Available constructors: `NotFound`, `BadRequest`, `Unauthorized`, `Forbidden`, `Conflict`, +`ValidationError`, `TooManyRequests`, `Internal`, `BadGateway`, `ServiceUnavailable`. -When the status is **dynamic** (e.g. proxying an upstream response) and the typed constructors don't fit, `New(status, msg)` builds an `AppError` for any status — deriving the same machine code the typed constructors use — and `Write(w, status, msg)` builds and writes it in one call: +When the status is **dynamic** (e.g. proxying an upstream response) and the typed constructors +don't fit, `New(status, msg)` builds an `AppError` for any status — deriving the same machine code +the typed constructors use — and `Write(w, status, msg)` builds and writes it in one call: ```go // Identical envelope to the typed constructors, with the caller's status preserved: @@ -329,11 +363,16 @@ apierror.BadRequest("invalid store ID").WithKey("errors.invalidStoreId").WriteJS apierror.ValidationError("validation failed", fieldErrors).WriteJSON(w) ``` +Full API: [pkg.go.dev/…/apierror](https://pkg.go.dev/github.com/ovander/backendkit/apierror). + --- ### ctxutil -Typed helpers for every Socrate JWT claim that `jwtauth` injects into the context. Every `Get*` function returns a single value and is safe to call even when the value is absent — it returns the zero value (`uuid.Nil`, `""`, `0`, or `nil`), except `GetUserPlan`, which defaults to `"freemium"`, and `GetLogger`, which falls back to the standard logger. +Typed helpers for every Socrate JWT claim that `jwtauth` injects into the context. Every `Get*` +function returns a single value and is safe to call even when the value is absent — it returns +the zero value (`uuid.Nil`, `""`, `0`, or `nil`), except `GetUserPlan`, which defaults to +`"freemium"`, and `GetLogger`, which falls back to the standard logger. ```go tenantID := ctxutil.GetTenantID(ctx) // uuid.UUID — uuid.Nil when absent @@ -348,19 +387,25 @@ requestID := ctxutil.GetRequestID(ctx) // string logger := ctxutil.GetLogger(ctx) // *logrus.Entry — falls back to standard logger rawJWT := ctxutil.GetRawJWT(ctx) // string — bearer token for forwarding to socrate.Client -// Multi-app role claims (app_roles) and the monotonic token_version: -roles := ctxutil.GetAppRoles(ctx) // map[string]string — clientID → role -role = ctxutil.GetAppRole(ctx, "my-app-id") // role within a specific app, "" if none -ver := ctxutil.GetTokenVersion(ctx) // int — 0 when absent +// Multi-app role claims (app_roles), the monotonic token_version and authentication facts: +roles := ctxutil.GetAppRoles(ctx) // map[string]string — clientID → role +role = ctxutil.GetAppRole(ctx, "my-app-id") // role within a specific app, "" if none +ver := ctxutil.GetTokenVersion(ctx) // int — 0 when absent +authTime := ctxutil.GetAuthTime(ctx) // int64 Unix seconds — 0 when absent +amr := ctxutil.GetAMR(ctx) // []string, e.g. ["pwd", "mfa"] — nil when absent ``` -> `GetTenantTier`/`WithTenantTier` are deprecated aliases for `GetUserPlan`/`WithUserPlan`; use the `*UserPlan` names in new code. +> `GetTenantTier`/`WithTenantTier` are deprecated aliases for `GetUserPlan`/`WithUserPlan`; use +> the `*UserPlan` names in new code. + +Full API: [pkg.go.dev/…/ctxutil](https://pkg.go.dev/github.com/ovander/backendkit/ctxutil). --- ### httpware -All middleware functions follow the standard `func(http.Handler) http.Handler` signature and work with any `net/http`-based router. +All middleware functions follow the standard `func(http.Handler) http.Handler` signature and work +with any `net/http`-based router. | Middleware | Constructor | |-----------|-------------| @@ -370,11 +415,14 @@ All middleware functions follow the standard `func(http.Handler) http.Handler` s | Body size limit | `httpware.BodyLimit(maxBytes)` | | Panic recovery | `httpware.Recover(entry)` — takes a `*logrus.Entry` | | Per-route timeout | `httpware.Timeout(d)` | -| Per-tenant rate limit | `httpware.NewRateLimiter(rps, burst)` | +| Per-tenant rate limit | `httpware.NewRateLimiter(rps, burst)` — mount `rl.Handler`, call `rl.Stop()` on shutdown | | Require a tenant | `httpware.RequireTenant` — 401 when no tenant is in context | | Role-based access | `httpware.NewRBAC(roleMap, entry)` | +| Prometheus RED metrics | `httpware.Metrics(service)` — mount first; `httpware.MetricsHandler()` serves the exposition | -> Note the logger types differ: `Logger` takes the base `*logrus.Logger` (it derives a request-scoped `*logrus.Entry` per request), while `Recover` and `NewRBAC` take a pre-enriched `*logrus.Entry`. +> Note the logger types differ: `Logger` takes the base `*logrus.Logger` (it derives a +> request-scoped `*logrus.Entry` per request), while `Recover` and `NewRBAC` take a pre-enriched +> `*logrus.Entry`. **RBAC — defining permissions:** @@ -392,7 +440,8 @@ rbac := httpware.NewRBAC(httpware.RoleMap{ r.With(rbac.Require(PermWriteReport)).Post("/reports", createReport) ``` -**Nested timeouts:** `httpware.Timeout` strips the existing deadline before applying the new one, so inner routes can safely override the global default: +**Nested timeouts:** `httpware.Timeout` strips the existing deadline before applying the new one, +so inner routes can safely override the global default: ```go r.Use(httpware.Timeout(10 * time.Second)) // global default @@ -403,13 +452,32 @@ r.Group(func(r chi.Router) { }) ``` +**Metrics.** `Metrics(service)` records +`backendkit_http_requests_total{service,route,method,status}` (status as a class such as `2xx`) +and `backendkit_http_request_duration_seconds{service,route,method}`. The `route` label is the +`net/http` `ServeMux` pattern that matched (`r.Pattern`, Go 1.22+), never the raw path; a request +without one is labelled `unmatched`. Mount it first so refused requests are counted, and serve +`MetricsHandler()` on a loopback listener or an admin-only route, never on a public host: + +```go +mux := http.NewServeMux() +mux.HandleFunc("GET /api/orders/{id}", getOrder) // route="GET /api/orders/{id}" +go http.ListenAndServe("127.0.0.1:9090", httpware.MetricsHandler()) +http.ListenAndServe(":8080", httpware.Metrics("orders")(mux)) +``` + +Full API: [pkg.go.dev/…/httpware](https://pkg.go.dev/github.com/ovander/backendkit/httpware). + --- ### gormlogger -Bridges GORM's internal logger to logrus. Slow queries (above the threshold) are logged at `Warn`; when `ignoreNotFound` is true, `ErrRecordNotFound` is demoted to `Debug` to avoid log noise in normal operation. +Bridges GORM's internal logger to logrus. Slow queries (above the threshold) are logged at +`Warn`; when `ignoreNotFound` is true, `ErrRecordNotFound` is demoted to `Debug` to avoid log noise +in normal operation. -`New` takes positional arguments — `New(entry, level, slowThreshold, ignoreNotFound)` — where `level` is a `gorm.io/gorm/logger.LogLevel`: +`New` takes positional arguments — `New(entry, level, slowThreshold, ignoreNotFound)` — where +`level` is a `gorm.io/gorm/logger.LogLevel`: ```go import glogger "gorm.io/gorm/logger" @@ -425,21 +493,28 @@ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ }) ``` -GORM hands the logger SQL with bound parameter values already interpolated, which -can contain PII or secrets. In production pass `gormlogger.WithSQLRedaction()` so -log records show `sql: "[redacted]"` while keeping timing, row count, and caller. +GORM hands the logger SQL with bound parameter values already interpolated, which can contain PII +or secrets. In production pass `gormlogger.WithSQLRedaction()` so log records show +`sql: "[redacted]"` while keeping timing, row count, and caller. + +Full API: [pkg.go.dev/…/gormlogger](https://pkg.go.dev/github.com/ovander/backendkit/gormlogger). --- ### socrate -> 📘 **Integrating an app?** See the [Socrate + backendkit Client Integration Guide](docs/CLIENT-INTEGRATION.md) — an end-to-end walkthrough for backend and frontend teams (middleware wiring, the two auth modes, login flows, error handling, and a full method reference). - -> **Socrate dependency note.** This client is purpose-built for Socrate and is not a generic OAuth2 or OIDC library. It assumes Socrate's specific API surface (dual user/admin ports, `client_credentials` service-account flow, magic-link endpoint). It will not work against Keycloak, Auth0, or other providers. +The client for the Socrate OAuth and admin APIs. It is written for Socrate's API surface (separate +OAuth and admin ports, the `client_credentials` service-account flow, magic links, the policy +decision point) and will not work against Keycloak, Auth0 or other providers. -The client uses a dual-auth strategy: user-scoped calls forward the caller's JWT; service-account calls acquire a `client_credentials` token automatically and cache it until near-expiry. +The client uses a dual-auth strategy: user-scoped calls forward the caller's JWT; service-account +calls acquire a `client_credentials` token automatically and cache it until near-expiry. -> **`AppID` is required for all service-account methods.** Service-account tokens carry `sub=app:{id}` and the Socrate admin routes cannot resolve the app ID at runtime without it. Always set `AppID` in `ClientConfig`; omitting it causes an immediate error on the first service-account call (`InviteUserAsService`, `RegisterUser`, `GetUserAsService`, `SendMagicLink`). +> **`AppID` is required for all service-account methods.** Service-account tokens carry +> `sub=app:{id}` and the Socrate admin routes cannot resolve the app ID at runtime without it. +> Always set `AppID` in `ClientConfig`; omitting it causes an immediate error on the first +> service-account call (`InviteUserAsService`, `RegisterUser`, `GetUserAsService`, +> `SendMagicLink`, `Decide`). ```go client, err := socrate.NewClient(socrate.ClientConfig{ @@ -466,63 +541,29 @@ if errors.Is(err, socrate.ErrUserAlreadyExists) { } ``` -**Magic-link (passwordless) authentication** - -Only the app backend may trigger magic-link emails — the endpoint is on the Socrate admin port and is M2M-only. `SendMagicLink` uses the service-account token automatically. - -```go -resp, err := client.SendMagicLink(ctx, "user@example.com") -if err != nil { - if errors.Is(err, socrate.ErrMagicLinkRateLimited) { - // 5 requests / hour per email + app pair - } - return err -} -// resp.Message is always the same opaque string (enumeration resistance). -// resp.MagicURL is non-empty in development mode only. -// -// The verify endpoint is POST-only (GET would let email scanners consume -// the single-use token before the user clicks). Your frontend reads -// ?token= and ?client_id= from the clicked URL and POSTs them: -// -// POST /api/auth/magic-link/verify -// {"token": "", "client_id": ""} -// -// On success Socrate returns an access_token, refresh_token, and id_token. -``` +Beyond user management, the client wraps the token flows a BFF needs (`ExchangeCode`, +`RefreshToken`, `VerifyMagicLink`, `Logout`), token introspection and revocation, magic links, +app and superadmin management, security monitoring and alerts, reports, the dashboard and audit +logs, and policy decisions (`Decide`). The +[client integration guide](docs/CLIENT-INTEGRATION.md#6-backend-the-socrateclient) explains the +auth modes and port routing and has the method reference, with the auth mode and port of each +call. -**Beyond user CRUD** - -The client also wraps the full Socrate admin and OAuth surface. All admin methods forward the caller's JWT and require an admin/superadmin token; the OAuth/token methods authenticate with the client credentials. - -| Area | Methods | -|------|---------| -| App-scoped users | `ListUsers`, `GetUser`, `CreateUser`, `UpdateUserRole`, `DeleteUser`, `ResendVerification`, `ForcePasswordReset` | -| Service-account (M2M) | `RegisterUser`, `InviteUserAsService`, `GetUserAsService`, `SendMagicLink` | -| OAuth / OIDC | `GetCurrentUserProfile`, `RevokeToken`, `IntrospectToken` | -| BFF token flows | `ExchangeCode`, `RefreshToken`, `VerifyMagicLink`, `AdminLogin`, `Logout` | -| User self-service profile | `GetProfile`, `UpdateProfile` | -| App management | `ListApps`, `GetApp`, `CreateApp`, `UpdateApp`, `DeleteApp`, `RotateSecret` | -| App activity logs | `GetAppLogs` | -| Global user admin | `AdminListUsers`, `AdminGetUser`, `AdminDeleteUser`, `GetUserApps`, `BlockUser`, `UnlockUser`, `RevokeUserTokens` | -| Sessions | `ListSessions`, `GetUserSessions` | -| Superadmins | `ListSuperadmins`, `GetSuperadmin`, `CreateSuperadmin`, `UpdateSuperadmin`, `DeleteSuperadmin` | -| Security monitoring | `GetThreatMetrics`, `ListBlockedIPs`, `BlockIP`, `UnblockIP`, `GetIPReputation`, `GetActivityLogs`, `GetGeoAnalytics`, `GetTokenStats`, `StreamSecurityEvents` | -| Alerts | `ListAlertRules`, `CreateAlertRule`, `UpdateAlertRule`, `DeleteAlertRule`, `GetAlertHistory`, `AcknowledgeAlert` | -| Reports | `GenerateSecurityReport`, `GetReportStatus`, `DownloadReport` | -| Dashboard & audit | `GetDashboardStats`, `GetDashboardHealth`, `GetDashboardActivity`, `GetLoginTrends`, `GetAppUsage`, `ListAdminLogs`, `GetAdminLog`, `GetAdminActivity`, `ExportAdminLogs`, `GetAdminProfile`, `GetAdminStats` | -| Server settings | `GetServerConfig`, `TestDB`, `TestCache` | +Full API: [pkg.go.dev/…/socrate](https://pkg.go.dev/github.com/ovander/backendkit/socrate). --- ### jwtauth -Validates RS256 JWTs issued by Socrate, caches JWKS public keys for 1 hour, and injects all Socrate claims into the request context. Stale keys are retained as a fallback when the JWKS endpoint is temporarily unreachable, so a Socrate restart does not immediately break live requests. +Validates RS256 JWTs issued by Socrate, caches JWKS public keys for 1 hour, and injects all +Socrate claims into the request context. Stale keys are retained as a fallback when the JWKS +endpoint is temporarily unreachable, so a Socrate restart does not immediately break live +requests. ```go auth := jwtauth.New( - "https://auth.example.com/.well-known/jwks.json", - "https://auth.example.com", + "https://socrate.example.com/.well-known/jwks.json", + "https://socrate.example.com", logger, ) r.Use(auth.Handler) @@ -532,30 +573,15 @@ tenantID := ctxutil.GetTenantID(r.Context()) // uuid.Nil if the token carried no plan := ctxutil.GetUserPlan(r.Context()) // "freemium" when absent ``` -**Audience validation (recommended).** When several services share one Socrate -issuer and JWKS, a token minted for one app is otherwise cryptographically valid -at another. Pass `jwtauth.WithAudience(clientID)` so a token is accepted only when -its `aud` claim contains this service's client ID: - -```go -auth := jwtauth.New( - jwksURL, issuer, logger, - jwtauth.WithAudience("my-app-client-id"), // reject tokens minted for other apps -) -``` - -Audience validation is opt-in for backward compatibility: when `WithAudience` is -omitted the `aud` claim is not checked. New services should set it. A token that -lacks an `aud` claim is rejected once an expected audience is configured. - -**Revocation (optional).** Local signature validation alone keeps a token valid -until its `exp`, even after logout or a password change. Pass -`jwtauth.WithRevocationCheck` to run a per-request check after validation — -typically comparing the token's `token_version` against the current value for the -user; returning an error rejects the request with 401: +Two opt-in options harden it; new services should set the first: ```go auth := jwtauth.New(jwksURL, issuer, logger, + // Accept a token only when its aud claim contains this service's client ID, + // so a token minted for another app on the same Socrate is refused. + jwtauth.WithAudience("my-app-client-id"), + + // Per-request revocation check after validation; an error rejects with 401. jwtauth.WithRevocationCheck(func(ctx context.Context, c *jwtauth.SocrateClaims) error { if c.TokenVersion < store.CurrentTokenVersion(ctx, c.Subject) { return errors.New("token_version superseded") @@ -564,12 +590,16 @@ auth := jwtauth.New(jwksURL, issuer, logger, })) ``` -The check is opt-in: with none configured, behaviour is unchanged. +- **Audience.** Without `WithAudience` the `aud` claim is not checked, for backward + compatibility. Once it is set, a token without `aud` is rejected. +- **Revocation.** Local signature validation alone keeps a token valid until its `exp`, even + after logout or a password change. The check typically compares `token_version` with the + user's current value; with none configured, behaviour is unchanged. +- **Authentication facts.** `auth_time` and `amr` (when and how the user authenticated) are + exposed as `ctxutil.GetAuthTime` / `ctxutil.GetAMR`. They are what a step-up or MFA check needs; + `pep` uses them to honour policy obligations. -**Authentication facts.** `auth_time` and `amr` (when and how the user -authenticated) are exposed as `ctxutil.GetAuthTime` / `ctxutil.GetAMR` — -zero / nil when the token does not carry them. They are what a step-up or MFA -check needs; `pep` uses them to honour policy obligations. +Full API: [pkg.go.dev/…/jwtauth](https://pkg.go.dev/github.com/ovander/backendkit/jwtauth). --- @@ -582,63 +612,23 @@ themselves come from the [`socrate`](#socrate) package — `*socrate.Client` is refresher. The Socrate admin and monitoring consoles run on this package. ```go -client, err := socrate.NewClient(socrate.ClientConfig{ - BaseURL: "https://socrate.example.com", ClientID: "my-bff", ClientSecret: "…", -}) -if err != nil { - log.Fatal(err) -} - store := bff.NewMemoryStore(30*time.Minute, 8*time.Hour) // idle, absolute; call store.Sweep() on a ticker gw := &bff.Gateway{ Store: store, Cookie: bff.CookieConfig{Name: "app_session", Secure: true}, // sent as __Host-app_session Refresher: client, // *socrate.Client refreshes the tokens } -login := bff.LoginBinding{Cookie: bff.CookieConfig{Name: "app_login", Secure: true}} - -// The browser starts here; it never sees a token. -mux.HandleFunc("/bff/login", func(w http.ResponseWriter, r *http.Request) { - p, state := bff.NewPKCE(), bff.RandomToken(32) - pending[state] = pendingLogin{ - Verifier: p.Verifier, - Nonce: login.Begin(w), // ties the callback to this browser - ReturnTo: bff.SanitizeReturnTo(r.URL.Query().Get("return_to")), - } - q := url.Values{ - "response_type": {"code"}, "client_id": {"my-bff"}, "redirect_uri": {redirectURI}, - "scope": {"openid profile email"}, "state": {state}, - "code_challenge": {p.Challenge}, "code_challenge_method": {"S256"}, - } - http.Redirect(w, r, "https://socrate.example.com/oauth/authorize?"+q.Encode(), http.StatusFound) -}) - -mux.HandleFunc("/bff/callback", func(w http.ResponseWriter, r *http.Request) { - st, ok := pending[r.URL.Query().Get("state")] - delete(pending, r.URL.Query().Get("state")) - if !ok || !login.Verify(w, r, st.Nonce) { - http.Error(w, "invalid login", http.StatusBadRequest) - return - } - ts, err := client.ExchangeCode(r.Context(), r.URL.Query().Get("code"), redirectURI, st.Verifier) - if err != nil { - http.Error(w, "login failed", http.StatusBadGateway) - return - } - s := bff.NewSession(bff.RandomToken(32), bff.RandomToken(32), ts, bff.UserInfo{ /* from the token claims */ }, time.Now()) - store.Put(s) - gw.Cookie.SetSession(w, s.ID()) - http.Redirect(w, r, st.ReturnTo, http.StatusFound) -}) // Every API call: session cookie in, bearer out. No valid session ⇒ 401, never a pass-through. // Unsafe methods must carry the session's CSRF token in X-CSRF-Token. mux.HandleFunc("/api/", gw.ProxyWithSession(bff.NewSingleHostProxy(apiURL))) ``` -`pending` stands for any server-side store of `{Verifier, Nonce, ReturnTo}` keyed by `state`, with -a short expiry. [`oauth2-admin/bff`](https://github.com/ovander/oauth2-admin/tree/main/bff) is a -complete production BFF built this way. +The login and callback handlers that create the session (`NewPKCE`, `LoginBinding`, +`SanitizeReturnTo`, `socrate.Client.ExchangeCode`, `NewSession`) are shown end to end in the +[client integration guide](docs/CLIENT-INTEGRATION.md#71-browser-apps-use-a-backend-for-frontend-bff). +[`oauth2-admin/bff`](https://github.com/ovander/oauth2-admin/tree/main/bff) is a complete BFF +built this way. **Safe by default.** @@ -646,23 +636,27 @@ complete production BFF built this way. |---|---| | No or expired session | `ProxyWithSession` answers **401**; it never forwards the request (opt-out: `AllowPassthrough`) | | CSRF | Unsafe methods need the session's token in `X-CSRF-Token` (constant-time compare), else **403** | -| Cookie | `HttpOnly`, and `__Host-` prefixed when `Secure` | +| Cookie | `HttpOnly`, `SameSite=Strict`, and `__Host-` prefixed when `Secure` | | Login CSRF / session swap | `LoginBinding` accepts a callback only from the browser that started the login | | Open redirect | `SanitizeReturnTo` keeps only same-site paths such as `/dashboard?x=1`; absolute URLs, `//host`, backslash and control-character tricks all become `/` | | Token refresh | Proactive, coalesced per session, detached from the triggering request, and written through to the store so the rotated refresh token is kept. Only a refresh the server rejects (`IsFatalRefreshError`) ends the session; a transient failure answers 502 and keeps it | -| Upstream attribution | `NewSingleHostProxy` strips client-supplied `X-Forwarded-For` and similar headers, so the browser cannot steer Socrate's rate limits, IP blocks or audit trail | +| Upstream attribution | `NewSingleHostProxy` strips client-supplied IP-attribution headers (`X-Real-IP`, `True-Client-IP`, `Forwarded`), so the browser cannot steer Socrate's rate limits, IP blocks or audit trail; `X-Forwarded-For` is left to the edge proxy | **Several instances.** `MemoryStore` is per process. Behind a load balancer, implement `SessionStore` (`Get`, `Put`, `Delete`, `Sweep`) over a shared database, serialising sessions -with `Session.Snapshot` / `NewSessionFromSnapshot`. +with `Session.Snapshot` / `NewSessionFromSnapshot`. A `Gateway` must be used by pointer and never +copied. + +Full API: [pkg.go.dev/…/bff](https://pkg.go.dev/github.com/ovander/backendkit/bff). --- ### pep -The policy enforcement point for Socrate's policy decision point (Socrate A4). -Rules live in Socrate — RBAC over roles, ABAC over user attributes, resource -attributes and request context — and every application asks the same PDP: +The policy enforcement point for Socrate's policy decision point: the Socrate endpoint that +evaluates an application's action against the central policy. Rules live in Socrate — RBAC over +roles, ABAC over user attributes, resource attributes and request context — and every +application asks the same decision point: ```go client, _ := socrate.NewClient(socrate.ClientConfig{ /* BaseURL, ClientID, ClientSecret, AppID */ }) @@ -674,28 +668,18 @@ r.Use(auth.Handler) // jwtauth first: the user's own token is the decision's sub r.With(enf.Middleware(func(r *http.Request) (string, socrate.PolicyResource, bool) { return "invoice.read", socrate.PolicyResource{Type: "invoice"}, true })).Get("/invoices", listInvoices) - -// Object-level: once the resource is loaded. -func approve(w http.ResponseWriter, r *http.Request) { - inv := load(r) - err := enf.Check(r.Context(), "invoice.approve", socrate.PolicyResource{ - Type: "invoice", ID: inv.ID, - Attributes: map[string]any{"amount": inv.Amount, "owner_id": inv.OwnerID}, - }, pep.ContextFor(r)) - if pep.WriteDenial(w, err) { - return - } - // … -} ``` -**Who decides what.** Socrate resolves the user — role, attributes, role in -*this* application, and from the token how and when they authenticated — so -nothing about the user is taken on the application's word. The application -supplies the action, the resource and the request context. +For object-level checks inside a handler (`Enforcer.Check` with the loaded resource's +attributes, then `pep.WriteDenial`), see the +[client integration guide](docs/CLIENT-INTEGRATION.md#10-enforcing-central-policy-decisions-pep). + +**Who decides what.** Socrate resolves the user — role, attributes, role in *this* application, +and from the token how and when they authenticated — so nothing about the user is taken on the +application's word. The application supplies the action, the resource and the request context. -**The mode comes from Socrate** with every decision, so one switch there -(`POLICY_MODE`) moves every application at once, with no redeploy: +**The mode comes from Socrate** with every decision, so one switch there (`POLICY_MODE`) moves +every application at once, with no redeploy: | Mode | A denial… | |---|---| @@ -704,21 +688,22 @@ supplies the action, the resource and the request context. | `enforce` | is refused: `403 {"error": "policy_denied"}` | An allow can carry **obligations**, honoured here against the verified token: -`require_fresh_auth` (within `FreshAuthMaxAge`, default 5 min) → -`403 elevation_required`; `require_mfa` (`amr` contains `mfa`) → -`403 mfa_required`. An obligation this version does not know is treated as -unmet, never dropped. - -**When Socrate cannot be reached** the last mode seen decides: proceed in -`off`/`shadow` (a shadow rollout can never take the application down), refuse -`503 policy_unavailable` in `enforce`. Before any decision has told the process -the mode it refuses too, unless `FailOpenWhenModeUnknown` is set. - -`ContextFor` sends the request's peer address as `context.ip`: behind a proxy, -resolve `RemoteAddr` with a trusted real-IP middleware first — a spoofable -`X-Forwarded-For` must never reach a policy. `CheckAsApp` decides for the -application itself (no user), e.g. in a background job. `OnDecision` is a hook -for metrics. +`require_fresh_auth` (within `FreshAuthMaxAge`, default 5 min) → `403 elevation_required`; +`require_mfa` (`amr` contains `mfa`) → `403 mfa_required`. An obligation this version does not +know is treated as unmet, never dropped. + +**When Socrate cannot be reached** the last mode seen decides: proceed in `off`/`shadow` (a +shadow rollout can never take the application down), refuse `503 policy_unavailable` in +`enforce`. Before any decision has told the process the mode it refuses too, unless +`FailOpenWhenModeUnknown` is set. A request with no user token in context (pep mounted before +jwtauth) is refused with 401, never decided as the application. + +`ContextFor` sends the request's peer address as `context.ip`: behind a proxy, resolve +`RemoteAddr` with a trusted real-IP middleware first — a spoofable `X-Forwarded-For` must never +reach a policy. `CheckAsApp` decides for the application itself (no user), e.g. in a background +job. `OnDecision` is a hook for metrics. + +Full API: [pkg.go.dev/…/pep](https://pkg.go.dev/github.com/ovander/backendkit/pep). --- @@ -739,7 +724,8 @@ reg.Normalise("UNKNOWN") // "freemium" (lowest tier) reg = tiering.NewPlanRegistry("starter", "growth", "enterprise") ``` -**Gate** — HTTP middleware that rejects requests below a plan threshold with a structured JSON error: +**Gate** — HTTP middleware that rejects requests below a plan threshold with a structured JSON +error: ```go gate := tiering.NewGate(tiering.DefaultRegistry(), logger, "/billing") @@ -751,7 +737,11 @@ r.With(gate.Require(tiering.PlanPro)).Post("/ai/narrate", handler) // "details":{"plan":"freemium","requiredPlan":"pro","upgradeUrl":"/billing"}}} ``` -**PolicyService** — per-feature rules stored in Postgres, cached in-process for 5 minutes. Implement `tiering.PolicyRepository` with your GORM repository to plug in persistence, then construct the service with `tiering.NewPolicyService(repo, registry, tiering.DefaultPlanSelector, logger)`. Every method takes a `context.Context` so cancellation and tracing propagate to the DB. +**PolicyService** — per-feature rules stored in your database, cached in-process for 5 minutes. +Implement `tiering.PolicyRepository` with your GORM repository to plug in persistence, then +construct the service with +`tiering.NewPolicyService(repo, registry, tiering.DefaultPlanSelector, logger)`. Every method +takes a `context.Context` so cancellation and tracing propagate to the DB. ```go // Seed baseline rules at startup: @@ -778,11 +768,15 @@ allowed := svc.IsAllowed(ctx, "ai_narration", plan) // false on deny or error limit := svc.NumericLimit(ctx, "export_limit", plan) // -1 = unlimited, 0 if absent ``` +Full API: [pkg.go.dev/…/tiering](https://pkg.go.dev/github.com/ovander/backendkit/tiering). + --- ### aigateway -Normalises OpenAI and Anthropic Claude into a single `Call(ctx, prompt) (string, error)` interface. Provider-specific configuration is handled at construction time; callers are provider-agnostic. +Normalises OpenAI and Anthropic Claude into a single `Call(ctx, prompt) (string, error)` +interface. Provider-specific configuration is handled at construction time; callers are +provider-agnostic. ```go ai := aigateway.New(aigateway.Config{ @@ -803,9 +797,16 @@ var data MyStruct err = aigateway.ExtractJSONInto(result, &data) // or ExtractJSON(result) for the raw string ``` -Set `Config.AllowedModels` to restrict which Claude models may be used; a call with an out-of-list model returns an error. `Client.IsConfigured()` reports whether an API key is present (handy for feature-flagging AI endpoints), and `Client.Provider()` returns the configured provider name. +Set `Config.AllowedModels` to restrict which Claude models may be used; a call with an +out-of-list model returns an error. `Client.IsConfigured()` reports whether an API key is present +(handy for feature-flagging AI endpoints), and `Client.Provider()` returns the configured provider +name. `NewAIClient` builds a language-safe client (wrapped with [`ailang`](#ailang)) and also +accepts `"ollama"` for a local model. -For tests, `aigateway.ClientForTest(provider, apiKey, serverURL)` points both provider base URLs at an `httptest.Server` so AI-dependent handlers can be exercised without a live API key. +For tests, `aigateway.ClientForTest(provider, apiKey, serverURL)` points both provider base URLs +at an `httptest.Server` so AI-dependent handlers can be exercised without a live API key. + +Full API: [pkg.go.dev/…/aigateway](https://pkg.go.dev/github.com/ovander/backendkit/aigateway). --- @@ -829,11 +830,15 @@ resp, err := guard.Generate(ctx, ailang.PromptInput{ Mismatches, retries and translation fallbacks are reported through the optional `EventReporter` (for example a Sentry adapter), so language drift is observable rather than silent. +Full API: [pkg.go.dev/…/ailang](https://pkg.go.dev/github.com/ovander/backendkit/ailang). + --- ### ainarration -A generic LRU+TTL cache for AI narration results, keyed by tenant and a content-addressed `CacheKey`. `NarrationCacher` is an interface — implement it with a DB-backed layer for persistence across restarts. +A generic LRU+TTL cache for AI narration results, keyed by tenant and a content-addressed +`CacheKey`. `NarrationCacher` is an interface — implement it with a DB-backed layer for +persistence across restarts. ```go cache := ainarration.NewNarrationCache(ainarration.DefaultCacheConfig()) @@ -853,11 +858,15 @@ cache.Put(tenantID, key, &ainarration.NarrationOutput{ }) ``` +Full API: [pkg.go.dev/…/ainarration](https://pkg.go.dev/github.com/ovander/backendkit/ainarration). + --- ### pagination -Query-parameter parsing for `page` and `per_page`, with defaults and upper-bound clamping (`DefaultPerPage = 20`, `MaxPerPage = 100`). Returns a `PagedResponse` envelope for consistent list API shapes. +Query-parameter parsing for `page` and `per_page`, with defaults and upper-bound clamping +(`DefaultPerPage = 20`, `MaxPerPage = 100`). Returns a `PagedResponse` envelope for consistent +list API shapes. ```go params := pagination.Parse(r) // reads ?page & ?per_page; page=1, perPage=20 by default @@ -867,11 +876,15 @@ resp := pagination.NewPagedResponse(items, params, total) // (data, params, tota // {"data": [...], "page": 1, "perPage": 20, "totalItems": 142, "totalPages": 8} ``` +Full API: [pkg.go.dev/…/pagination](https://pkg.go.dev/github.com/ovander/backendkit/pagination). + --- ### buildinfo -Exposes build-time metadata injected via `-ldflags` and a ready-to-mount version handler. The `Version`, `BuildTime`, and `GitCommit` package variables are link-time targets; they fall back to safe defaults (`Version = "dev"`) when unset. +Exposes build-time metadata injected via `-ldflags` and a ready-to-mount version handler. The +`Version`, `BuildTime`, and `GitCommit` package variables are link-time targets; they fall back +to safe defaults (`Version = "dev"`) when unset. ```makefile LDFLAGS := \ @@ -889,11 +902,33 @@ info := buildinfo.Get() // {"version":"v1.2.3","buildTime":"...","gitCommit":"a1b2c3d","goVersion":"go1.25"} ``` +Full API: [pkg.go.dev/…/buildinfo](https://pkg.go.dev/github.com/ovander/backendkit/buildinfo). + +--- + +## Environment variables + +backendkit **reads no environment variables itself** — you pass configuration explicitly to each +constructor. The variables below are the conventions used in this README's examples; name them +however you like in your own service. + +| Variable | Consumed by | Purpose | +|----------|-------------|---------| +| `SOCRATE_JWKS_URL` | `jwtauth.New` | JWKS endpoint used to validate RS256 signatures, e.g. `https://socrate.example.com/.well-known/jwks.json` | +| `SOCRATE_ISSUER` | `jwtauth.New` | Expected `iss` claim — optional; enforced only when non-empty (an empty value logs a warning at startup) | +| `SOCRATE_BASE_URL` | `socrate.NewClient` | Socrate OAuth port base URL (e.g. `https://socrate.example.com`) | +| `SOCRATE_ADMIN_BASE_URL` | `socrate.NewClient` | Socrate admin API base URL — optional; derived from `SOCRATE_BASE_URL` with port 8081 when empty | +| `SOCRATE_CLIENT_ID` | `socrate.NewClient`, `jwtauth.WithAudience` | OAuth client ID | +| `SOCRATE_CLIENT_SECRET` | `socrate.NewClient` | Client secret — required for service-account calls, `Decide`, `RevokeToken`, `IntrospectToken` and a BFF's token exchange | +| `SOCRATE_APP_ID` | `socrate.NewClient` | Pre-resolved numeric app ID — **required** for every service-account method, including `Decide` | +| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | `aigateway.New` | Provider API key for the configured provider | + --- ## Testing -Because every claim helper reads from `context.Context`, you can exercise gated handlers without minting real JWTs — just seed the context the way `jwtauth` would: +Because every claim helper reads from `context.Context`, you can exercise gated handlers without +minting real JWTs — just seed the context the way `jwtauth` would: ```go import ( @@ -911,7 +946,8 @@ req = req.WithContext(ctx) // ...serve req through your gate/RBAC middleware and assert on the recorder. ``` -The AI gateway ships a test constructor so handlers that call a provider can run against an `httptest.Server` with no real API key: +The AI gateway ships a test constructor so handlers that call a provider can run against an +`httptest.Server` with no real API key: ```go srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -923,7 +959,12 @@ ai := aigateway.ClientForTest("claude", "test-key", srv.URL) out, _ := ai.Call(context.Background(), "ping") // → "hello" ``` -`ainarration.NarrationCache.Flush()` resets the cache between test cases, and each package ships runnable `Example*` functions (visible on [pkg.go.dev](https://pkg.go.dev/github.com/ovander/backendkit)) that double as living usage docs. +The Socrate-facing seams are interfaces, so tests need no Socrate server: `pep.Config.Decider` +takes any `pep.Decider` (seed the token with `ctxutil.WithRawJWT`), and `bff.Gateway.Refresher` +takes any `bff.TokenRefresher`; `bff.Gateway.Now` fixes the clock. +`ainarration.NarrationCache.Flush()` resets the cache between test cases, and each package ships +runnable `Example*` functions (visible on +[pkg.go.dev](https://pkg.go.dev/github.com/ovander/backendkit)) that double as usage docs. --- @@ -931,72 +972,65 @@ out, _ := ai.Call(context.Background(), "ping") // → "hello" | Symptom | Likely cause & fix | |---------|--------------------| -| **Every request returns 401** | No `Authorization: Bearer ` header, an `iss` that doesn't match `SOCRATE_ISSUER`, or the JWKS URL is unreachable. Stale keys are reused on a *transient* fetch failure, but a wrong/empty JWKS URL fails closed. | +| **Every request returns 401** | No `Authorization: Bearer ` header, an `iss` that doesn't match `SOCRATE_ISSUER`, an `aud` that doesn't contain the `WithAudience` value, or the JWKS URL is unreachable. Stale keys are reused on a *transient* fetch failure, but a wrong/empty JWKS URL fails closed. | | **`GetTenantID` is `uuid.Nil` / `GetUserPlan` is always `"freemium"`** | `tenant_id` and `plan` are **custom** claims. A stock Socrate server does not emit them — configure Socrate to include them, or these helpers return their zero/default values by design. | | **`GetUserEmail` / `GetUserName` are empty** | Email and name live in the **ID token**, not the access token. For access-token requests, fetch them via `socrate.Client.GetCurrentUserProfile`. | | **Compile error passing a logger to `httpware.Logger`** | `Logger` takes the base `*logrus.Logger`; `Recover`, `NewRBAC`, `jwtauth.New`, and `tiering.NewGate` take a `*logrus.Entry`. See the [httpware](#httpware) note. | | **Service-account call errors with "AppID must be set"** | Set `AppID` in `ClientConfig` (`SOCRATE_APP_ID`). The `/api/admin/apps` lookup needs a human-admin JWT, so a service token cannot resolve the app ID at runtime. | | **Rate limiter never limits** | `RateLimiter` keys on the tenant UUID and lets requests through when none is present. Place `rl.Handler` **after** `auth.Handler` so `tenant_id` is already in context. | | **`tiering.Gate` always allows / always denies** | The gate reads the plan from context (`ctxutil.GetUserPlan`); confirm auth runs before the gate and that your `PlanRegistry` contains the plan names you check. Unknown plans normalise to the lowest tier. | +| **BFF answers 403 on `POST`/`PUT`/`DELETE`** | The request lacks the session's CSRF token in `X-CSRF-Token`. Give the SPA the token (for example in your `/bff/session` response) and send it on every unsafe request. | +| **Every `pep` check answers 401** | `pep` runs before `jwtauth`, so there is no user token in context. Mount `auth.Handler` first. | +| **`pep` answers `503 policy_unavailable` at startup** | Socrate's decision point was unreachable before any decision told the process the mode. Check `SOCRATE_BASE_URL`, `SOCRATE_CLIENT_SECRET` and `SOCRATE_APP_ID`; set `FailOpenWhenModeUnknown` only if proceeding is acceptable while the mode is unknown. | --- -## Production usage - -backendkit is extracted from and actively used in production by: - -- **Kerplan** — a multi-tenant enterprise SaaS platform. The full middleware stack, tiering, Socrate client, and aigateway packages are in use. -- **ParaShift** — a backend service using jwtauth, httpware, and gormlogger for structured observability. +## Used by -The library follows a conservative compatibility policy: no breaking changes within a major version. +- [ovander/oauth2-admin](https://github.com/ovander/oauth2-admin) — the Socrate superadmin + console; its BFF (`bff/`) is built on `bff` and `socrate`. +- [ovander/oauth2-monitoring](https://github.com/ovander/oauth2-monitoring) — the Socrate + security monitoring console; its BFF (`bff/`) is built on `bff` and `socrate`. +- [ovander/ascenda-backend](https://github.com/ovander/ascenda-backend) — a multi-tenant + financial planning API using `jwtauth`, `httpware`, `ctxutil`, `apierror`, `socrate`, + `tiering`, `pagination`, `gormlogger`, `ainarration` and `buildinfo`. --- ## Versioning -backendkit follows [Semantic Versioning](https://semver.org): +backendkit follows [Semantic Versioning](https://semver.org), with no breaking change within a +major version: - **Patch** (`v1.x.y`) — bug fixes and non-breaking internal changes. - **Minor** (`v1.x.0`) — new exported symbols, new packages, backward-compatible additions. -- **Major** (`v2.0.0`) — breaking changes to existing exported APIs. A new major version requires updating the import path (`github.com/ovander/backendkit/v2`). +- **Major** (`v2.0.0`) — breaking changes to existing exported APIs. A new major version requires + updating the import path (`github.com/ovander/backendkit/v2`). -Always pin an explicit version in `go.mod` rather than using `@latest` to keep builds reproducible. +Always pin an explicit version in `go.mod` rather than using `@latest` to keep builds +reproducible. --- -## Contributing - -**Local development** — after cloning: +## Status -```bash -go mod tidy # resolve and pin all dependencies into go.sum -go test ./... # run all tests -go test -race ./... # race-detector pass -go vet ./... # static analysis -``` - -**Guidelines:** +backendkit is in active use as part of the Socrate suite. Current focus: -1. Add your package under its own directory with a package-level doc comment. -2. Write table-driven tests; place `_test.go` files in the same package directory. -3. Add runnable examples in `example_test.go` — they appear on pkg.go.dev. -4. All exported symbols must have Go doc comments that begin with the symbol name. -5. Run `go test -race ./...` and `go vet ./...` before opening a PR. -6. Keep packages loosely coupled — every package may use the shared `ctxutil` and `apierror` primitives; beyond those, the only intra-module imports are `bff` → `socrate`, `pep` → `socrate` and `aigateway` → `ailang`. Discuss any new one first. - -The full workflow — required checks, commit style, changelog and pull-request template — is in -[CONTRIBUTING.md](CONTRIBUTING.md). +- Rolling out `pep` so applications enforce Socrate's central policy decisions. +- Keeping `v1` backward compatible: hardening such as audience validation stays opt-in, and + making it the default waits for a `v2`. --- -## Security +## Contributing -Please report vulnerabilities privately through the repository's **Security** tab → **Report a -vulnerability**, not in a public issue. Scope and supported versions are in -[SECURITY.md](SECURITY.md). +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the checks CI runs, and the pull-request +workflow; changes are listed in [CHANGELOG.md](CHANGELOG.md). Report vulnerabilities privately as +described in [SECURITY.md](SECURITY.md). --- ## License -backendkit is licensed under the [Apache License 2.0](LICENSE). +Copyright © 2026 Olivier Vandermoten. Licensed under the Apache License, Version 2.0; see +[LICENSE](LICENSE). SPDX-License-Identifier: Apache-2.0 diff --git a/SECURITY.md b/SECURITY.md index 0511ed8..71b37bb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,9 +20,8 @@ they are ready, and the report is credited in the release notes unless you prefe - In scope: the packages in this repository, in particular `jwtauth` (token verification and JWKS handling), `bff` (sessions, cookies, CSRF, PKCE, the session→bearer proxy), `pep`, `socrate` and `httpware`. -- Out of scope: the Socrate identity provider itself (report those in - [`ovander/go-oauth2`](https://github.com/ovander/go-oauth2)), flaws in an application's own - code or configuration, and denial-of-service by volume. +- Out of scope: the Socrate identity provider itself (`ovander/go-oauth2`, not public yet), flaws + in an application's own code or configuration, and denial-of-service by volume. ## Supported versions @@ -31,5 +30,19 @@ backward-compatible. ## Past reviews -The library has been through internal security and architecture reviews. The fixes they led to -are listed in [`CHANGELOG.md`](CHANGELOG.md), with the finding IDs they close (`F-n`, `INV-n`). +The library has been through internal security and architecture reviews. The controls they led +to include: + +- **Token verification (`jwtauth`):** optional audience and revocation checks, a required `exp` + with bounded clock-skew leeway, a 2048-bit minimum for JWKS keys, and a JWKS refetch that is + coalesced, rate-limited and negatively cached so unknown key IDs cannot force a fetch per + request. +- **Sessions (`bff`):** a fail-closed zero value, CSRF that never matches an empty token, token + refresh coalesced per session and detached from the triggering request, login binding against + login CSRF, and stripping of client-supplied IP-attribution headers. +- **Upstream calls (`socrate`, `aigateway`):** capped response reads and escaped path and query + values. +- **Error and log output (`apierror`, `gormlogger`):** 5xx responses redacted, and SQL values + kept out of logs with `WithSQLRedaction`. + +Each fix is listed in [CHANGELOG.md](CHANGELOG.md) with the version that shipped it. diff --git a/docs/CLIENT-INTEGRATION.md b/docs/CLIENT-INTEGRATION.md index 42c6bdd..8acc833 100644 --- a/docs/CLIENT-INTEGRATION.md +++ b/docs/CLIENT-INTEGRATION.md @@ -1,21 +1,22 @@ # Socrate + backendkit — Client Integration Guide -A practical, end-to-end guide for **application teams** integrating with the -[Socrate](https://github.com/ovander/go-oauth2) OAuth 2.0 / OpenID Connect server -through the `backendkit` library. +A practical, end-to-end guide for **application teams** integrating with Socrate, the suite's +OAuth 2.1 / OpenID Connect server (`ovander/go-oauth2`, not public yet), through the +`backendkit` library. It is written for two audiences working on the same product: - **Backend engineers** building a Go service that trusts Socrate-issued JWTs - and needs to manage users, profiles and security data. -- **Frontend engineers** (SPA / mobile / native) driving the login flow and - calling that backend. + and needs to manage users, profiles and security data, or a + Backend-for-Frontend (BFF) that signs browser users in. +- **Frontend engineers** driving the login flow and calling that backend: + browser apps through a BFF, mobile, native and CLI clients with their own + tokens. > **Reference vs. guide.** This document is the *client-side* integration -> guide. The authoritative description of every raw HTTP endpoint lives in the -> server repo at [`go-oauth2/docs/API.md`](https://github.com/ovander/go-oauth2/blob/main/docs/API.md). -> When the two disagree, the server doc wins. This guide tells you how to wire -> things up; that doc tells you exactly what each endpoint returns. +> guide: it tells you how to wire things up. The raw HTTP endpoints are +> described in the Socrate server repository, which is not public yet; when +> this guide and the server disagree, the server wins. --- @@ -32,15 +33,18 @@ It is written for two audiences working on the same product: - [6.3 Dual-port routing](#63-dual-port-routing) - [6.4 Method reference](#64-method-reference) - [7. Frontend: driving the login flow](#7-frontend-driving-the-login-flow) - - [7.1 Authorization Code + PKCE (recommended)](#71-authorization-code--pkce-recommended) - - [7.2 Direct JSON login (first-party only)](#72-direct-json-login-first-party-only) - - [7.3 Magic link (passwordless)](#73-magic-link-passwordless) - - [7.4 Calling your backend](#74-calling-your-backend) + - [7.1 Browser apps: use a Backend-for-Frontend (bff)](#71-browser-apps-use-a-backend-for-frontend-bff) + - [7.2 Alternative: clients that hold their own tokens](#72-alternative-clients-that-hold-their-own-tokens) + - [7.3 Authorization Code + PKCE in the client](#73-authorization-code--pkce-in-the-client) + - [7.4 Direct JSON login (first-party only)](#74-direct-json-login-first-party-only) + - [7.5 Magic link (passwordless)](#75-magic-link-passwordless) + - [7.6 Calling your backend with a bearer token](#76-calling-your-backend-with-a-bearer-token) - [8. Error handling](#8-error-handling) - [9. Role & plan gating](#9-role--plan-gating) -- [10. Recipes](#10-recipes) -- [11. Quick reference](#11-quick-reference) -- [12. Gotchas & FAQ](#12-gotchas--faq) +- [10. Enforcing central policy decisions (pep)](#10-enforcing-central-policy-decisions-pep) +- [11. Recipes](#11-recipes) +- [12. Quick reference](#12-quick-reference) +- [13. Gotchas & FAQ](#13-gotchas--faq) --- @@ -52,35 +56,44 @@ surface for managing all of that. Your application is split into a **frontend** and a **backend**: -- The **frontend** never validates tokens. It obtains them from Socrate (via the - Authorization Code flow, direct login, or magic link) and sends them as - `Authorization: Bearer ` to your backend. +- The **frontend** never validates tokens. A **browser app** should never hold + them either: it signs in through a Backend-for-Frontend (BFF) and gets only an + opaque session cookie (§7.1). A **mobile, native or CLI client** obtains tokens + from Socrate (Authorization Code + PKCE, direct login, or magic link) and sends + them as `Authorization: Bearer ` to your backend (§7.2). +- The **BFF** (`bff`) is a small server-side component, often part of your + backend, that runs the login with Socrate, keeps the tokens in a server-side + session, and forwards each browser request to your API with the bearer + attached. - The **backend** validates every incoming token against Socrate's public keys - (JWKS), reads the user's identity and role from the verified claims, and — - when it needs to act on Socrate (list users, invite a teammate, look up a - profile) — calls Socrate through the `socrate.Client`. + (JWKS), reads the user's identity and role from the verified claims, asks + Socrate's policy decision point when an action needs a central decision + (`pep`), and — when it needs to act on Socrate (list users, invite a teammate, + look up a profile) — calls Socrate through the `socrate.Client`. -`backendkit` is the glue for the backend half: JWT validation +`backendkit` is the glue for the server side: JWT validation (`jwtauth`), claim propagation (`ctxutil`), the typed Socrate API client -(`socrate`), a middleware stack (`httpware`), structured errors (`apierror`), -and plan-based feature gating (`tiering`). +(`socrate`), the BFF runtime (`bff`), policy enforcement (`pep`), a middleware +stack (`httpware`), structured errors (`apierror`), and plan-based feature +gating (`tiering`). ``` - ┌────────────────────────────────────┐ - │ Socrate │ - login / tokens │ :8080 OAuth/OIDC (public) │ - ┌────────────────────▶ │ /oauth/* /.well-known/* │ - │ │ :8081 Admin API (internal) │ - │ │ /api/admin/* /api/apps/* │ - │ └────────────────────────────────────┘ - │ ▲ ▲ - │ │ JWKS (validate) │ socrate.Client - │ │ │ (JWT-forward + M2M) - │ Bearer JWT ┌─────┴─────────────────────┴──────┐ -┌─┴────────────┐ API │ Your backend (Go) │ -│ Frontend │ ──────▶ │ jwtauth → ctxutil → httpware → │ -│ SPA / mobile │ │ your handlers → socrate.Client │ -└──────────────┘ └───────────────────────────────────┘ + ┌─────────────────────────────────────────────────────┐ + │ Socrate │ + │ OAuth/OIDC (public) /oauth/* /.well-known/* │ + │ Admin API (internal) /api/admin/* /api/apps/* │ + └───────┬─────────────────────────┬──────────────┬────┘ + │ │ │ + code exchange │ JWKS │ │ socrate.Client: + and refresh │ │ │ JWT forward, M2M, + │ │ │ policy decisions +┌──────────────┐ cookie ┌────┴──────────┐ Bearer ┌───┴──────────────┴───────────────┐ +│ Browser SPA │ ───────▶ │ BFF (bff) │ ───────▶ │ Your backend (Go) │ +└──────────────┘ └───────────────┘ │ jwtauth → ctxutil → httpware → │ + │ handlers → pep, socrate.Client │ +┌──────────────┐ Bearer JWT │ │ +│ Mobile / CLI │ ──────────────────────────────────▶ │ │ +└──────────────┘ └──────────────────────────────────┘ ``` --- @@ -89,14 +102,18 @@ and plan-based feature gating (`tiering`). | Actor | Talks to | How | Auth | |-------|----------|-----|------| -| Frontend | **Socrate :8080** | Authorization Code + PKCE, or `POST /api/auth/login` | none → receives tokens | -| Frontend | **Your backend** | your REST API | `Bearer ` | -| Your backend | **Socrate :8080** | `jwtauth` fetches JWKS; `socrate.Client` calls `/oauth/*` | JWKS is public; introspect/revoke use client creds | -| Your backend | **Socrate :8081** | `socrate.Client` admin & app-user calls | forwards the user JWT **or** a service-account token | - -The **:8081 admin port is internal**. Your frontend must never reach it -directly — all admin/app-user operations go *through your backend* via the -`socrate.Client`, which lets you enforce your own authorization first. +| Browser app | **Your BFF** (same origin) | your REST API, proxied | opaque `__Host-` session cookie + `X-CSRF-Token` on unsafe methods | +| Your BFF | **Socrate OAuth port** | Authorization Code + PKCE, refresh (`socrate.Client`) | client ID + secret | +| Your BFF | **Your backend** | proxied request (`bff.Gateway`) | `Bearer ` from the session | +| Mobile / native / CLI client | **Socrate OAuth port** | Authorization Code + PKCE, or `POST /api/auth/login` | none → receives tokens | +| Mobile / native / CLI client | **Your backend** | your REST API | `Bearer ` | +| Your backend | **Socrate OAuth port** | `jwtauth` fetches JWKS; `socrate.Client` calls `/oauth/*` | JWKS is public; introspect/revoke use client creds | +| Your backend | **Socrate admin API port** | `socrate.Client` admin, app-user and policy calls | forwards the user JWT **or** a service-account token | + +Socrate's **admin API port** (8081 in the default deployment) is internal. +Your frontend must never reach it directly — all admin, app-user and policy +operations go *through your backend* via the `socrate.Client`, which lets you +enforce your own authorization first. --- @@ -114,12 +131,12 @@ guide: | Variable | Consumed by | Purpose | |----------|-------------|---------| -| `SOCRATE_JWKS_URL` | `jwtauth.New` | JWKS endpoint, e.g. `https://auth.example.com/.well-known/jwks.json` | +| `SOCRATE_JWKS_URL` | `jwtauth.New` | JWKS endpoint, e.g. `https://socrate.example.com/.well-known/jwks.json` | | `SOCRATE_ISSUER` | `jwtauth.New` | Expected `iss` claim — optional but recommended in production | -| `SOCRATE_BASE_URL` | `socrate.NewClient` | OAuth (public) port base URL, e.g. `https://auth.example.com` | -| `SOCRATE_ADMIN_BASE_URL` | `socrate.NewClient` | Admin port base URL — derived from `BaseURL` (`:8081`) if omitted | +| `SOCRATE_BASE_URL` | `socrate.NewClient` | OAuth (public) port base URL, e.g. `https://socrate.example.com` | +| `SOCRATE_ADMIN_BASE_URL` | `socrate.NewClient` | Admin API base URL — optional; derived from `BaseURL` with port 8081 (the default deployment's admin port) if omitted | | `SOCRATE_CLIENT_ID` | `socrate.NewClient` | Your app's OAuth client ID | -| `SOCRATE_CLIENT_SECRET` | `socrate.NewClient` | Client secret — required for service-account calls, `RevokeToken`, `IntrospectToken` | +| `SOCRATE_CLIENT_SECRET` | `socrate.NewClient` | Client secret — required for service-account calls, `Decide`, `RevokeToken`, `IntrospectToken` and a BFF's token exchange | | `SOCRATE_APP_ID` | `socrate.NewClient` | Pre-resolved numeric app ID — **required** for every service-account method | > **Get these values** by registering your app in Socrate (Admin API @@ -285,7 +302,7 @@ client, err := socrate.NewClient(socrate.ClientConfig{ ClientID: os.Getenv("SOCRATE_CLIENT_ID"), // required ClientSecret: os.Getenv("SOCRATE_CLIENT_SECRET"), // service-account / introspect / revoke AppID: os.Getenv("SOCRATE_APP_ID"), // required for service-account calls - // AdminBaseURL: "https://auth.example.com:8081", // optional; derived from BaseURL if empty + // AdminBaseURL: "https://socrate.example.com:8081", // optional; derived from BaseURL if empty // Timeout: 30 * time.Second, // optional; default 30s }) if err != nil { @@ -341,14 +358,16 @@ inv, err := client.InviteUserAsService(ctx, socrate.ServiceInviteRequest{ The client routes each call to the correct port automatically — you never build URLs yourself: -- **OAuth port** (`BaseURL`, `:8080`): `GetCurrentUserProfile`, `RevokeToken`, - `IntrospectToken`, and the internal token exchange. -- **Admin port** (`AdminBaseURL`, `:8081`): everything else — app-user - management, app management, superadmins, security, dashboard, audit logs, - magic links. +- **OAuth port** (`BaseURL`, the public port): `GetCurrentUserProfile`, + `RevokeToken`, `IntrospectToken`, and the token endpoint calls + (`ExchangeCode`, `RefreshToken`, the service-account token). +- **Admin API port** (`AdminBaseURL`; 8081 in the default deployment): + everything else — app-user management, app management, superadmins, + security, dashboard, audit logs, magic links, policy decisions. `AdminBaseURL` defaults to `BaseURL` with the host port replaced by `8081`. -Override it explicitly if your admin port lives on a different host. +Set it explicitly when your deployment differs, for example when the admin API +listens on another host or port. ### 6.4 Method reference @@ -403,6 +422,12 @@ automatically from `client_id` (cached). |--------|------|---------|-------| | `SendMagicLink(ctx, email)` | M2M | `*MagicLinkResponse` | opaque 202 (enumeration-safe); `ErrMagicLinkRateLimited` on 429 (5/hr per email+app). `MagicURL` is non-empty in dev mode only. | +#### Policy decisions — Admin port + +| Method | Auth | Returns | Notes | +|--------|------|---------|-------| +| `Decide(ctx, DecideRequest)` | M2M | `*Decision` | asks Socrate's policy decision point; `ErrPolicyUnavailable` on 503, with the mode kept in the `Decision`. Usually called through `pep` (§10). | + #### App (client) management — Admin port · admin JWT | Method | Auth | Returns | @@ -494,20 +519,190 @@ automatically from `client_id` (cached). ## 7. Frontend: driving the login flow -The frontend's job is to obtain tokens from Socrate and attach them to backend -requests. Pick **one** primary flow. +For a **browser app**, use a Backend-for-Frontend (§7.1). The browser then +never sees an access or refresh token: an XSS bug cannot steal one, and there is +no token to keep in `localStorage` or `sessionStorage`. The Socrate admin and +monitoring consoles work this way. + +The flows where the client obtains and holds its own tokens (§7.2–§7.6) remain +for **mobile, native and CLI clients**, and for a legacy SPA that has not moved +to a BFF yet. + +### 7.1 Browser apps: use a Backend-for-Frontend (bff) + +The BFF is the confidential OAuth client. It runs Authorization Code + PKCE +server-side, keeps the tokens in a server-side session, and gives the browser +only an opaque `HttpOnly`, `SameSite=Strict`, `__Host-` session cookie. The SPA +calls same-origin paths; the BFF looks up the session, refreshes the access +token when it is about to expire, and forwards the request to your API with +`Authorization: Bearer` attached. Your API validates that token with `jwtauth` +exactly as in §4. + +A minimal BFF with the `bff` package and `socrate.Client`: + +```go +package main + +import ( + "encoding/json" + "log" + "net/http" + "net/url" + "os" + "sync" + "time" + + "github.com/ovander/backendkit/bff" + "github.com/ovander/backendkit/socrate" +) + +// pendingLogin is what the BFF remembers between /bff/login and /bff/callback. +type pendingLogin struct { + Verifier, Nonce, ReturnTo string + Expires time.Time +} + +func main() { + const socrateURL = "https://socrate.example.com" + const redirectURI = "https://app.example.com/bff/callback" + apiURL, _ := url.Parse("http://127.0.0.1:9000") // your API, reachable only from the BFF + + client, err := socrate.NewClient(socrate.ClientConfig{ + BaseURL: socrateURL, + ClientID: os.Getenv("SOCRATE_CLIENT_ID"), + ClientSecret: os.Getenv("SOCRATE_CLIENT_SECRET"), // the BFF is a confidential client + }) + if err != nil { + log.Fatal(err) + } + + store := bff.NewMemoryStore(30*time.Minute, 8*time.Hour) // idle, absolute + go func() { + for range time.Tick(time.Minute) { + store.Sweep() + } + }() + + gw := &bff.Gateway{ // use by pointer; never copy + Store: store, + Cookie: bff.CookieConfig{Name: "app_session", Secure: true}, // sent as __Host-app_session + Refresher: client, // *socrate.Client refreshes tokens + } + login := bff.LoginBinding{Cookie: bff.CookieConfig{Name: "app_login", Secure: true}} + + var mu sync.Mutex + pending := map[string]pendingLogin{} // keyed by state; use a shared store with several instances + + mux := http.NewServeMux() + + mux.HandleFunc("GET /bff/login", func(w http.ResponseWriter, r *http.Request) { + p, state := bff.NewPKCE(), bff.RandomToken(32) + mu.Lock() + pending[state] = pendingLogin{ + Verifier: p.Verifier, + Nonce: login.Begin(w), // ties the callback to this browser + ReturnTo: bff.SanitizeReturnTo(r.URL.Query().Get("return_to")), + Expires: time.Now().Add(bff.DefaultLoginBindingTTL), + } + mu.Unlock() + q := url.Values{ + "response_type": {"code"}, "client_id": {os.Getenv("SOCRATE_CLIENT_ID")}, + "redirect_uri": {redirectURI}, "scope": {"openid profile email"}, "state": {state}, + "code_challenge": {p.Challenge}, "code_challenge_method": {"S256"}, + } + http.Redirect(w, r, socrateURL+"/oauth/authorize?"+q.Encode(), http.StatusFound) + }) + + mux.HandleFunc("GET /bff/callback", func(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + mu.Lock() + st, ok := pending[state] + delete(pending, state) // single use + mu.Unlock() + if !ok || time.Now().After(st.Expires) || !login.Verify(w, r, st.Nonce) { + http.Error(w, "invalid login", http.StatusBadRequest) + return + } + ts, err := client.ExchangeCode(r.Context(), r.URL.Query().Get("code"), redirectURI, st.Verifier) + if err != nil { + http.Error(w, "login failed", http.StatusBadGateway) + return + } + user := bff.UserInfo{Roles: ts.Roles} + if p, err := client.GetCurrentUserProfile(socrate.WithJWT(r.Context(), ts.AccessToken)); err == nil && p != nil { + user.Sub, user.Email, user.Name = p.Sub, p.Email, p.Name + } + s := bff.NewSession(bff.RandomToken(32), bff.RandomToken(32), ts, user, time.Now()) + store.Put(s) + gw.Cookie.SetSession(w, s.ID()) + http.Redirect(w, r, st.ReturnTo, http.StatusFound) + }) + + // The SPA learns who is signed in, and the CSRF token to echo, from here. + mux.HandleFunc("GET /bff/session", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + s, ok := gw.SessionFromRequest(r) + if !ok { + _ = json.NewEncoder(w).Encode(map[string]any{"authenticated": false}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"authenticated": true, "user": s.User(), "csrf": s.CSRF()}) + }) -> **Prefer a BFF?** If you'd rather keep tokens server-side and out of the -> browser entirely, let the frontend send the `code` (and your own session -> cookie) to your backend, and do the exchange there with -> `client.ExchangeCode` / `client.RefreshToken` / `client.VerifyMagicLink` -> (§6.4, "BFF token flows"). The browser steps below collapse to "redirect, -> then hand the `code` to my backend". + // Every API call: session cookie in, bearer out. No valid session ⇒ 401. + // Unsafe methods must carry the session's CSRF token in X-CSRF-Token. + mux.HandleFunc("/api/", gw.ProxyWithSession(bff.NewSingleHostProxy(apiURL))) -### 7.1 Authorization Code + PKCE (recommended) + log.Fatal(http.ListenAndServe("127.0.0.1:8080", mux)) // behind your TLS edge proxy +} +``` -The standard, most secure browser flow. Works for public clients (SPA/mobile) -with **no client secret**. +The SPA side is small: navigate to `/bff/login?return_to=/current/path` to sign +in, read `GET /bff/session` to learn who is signed in, and send the `csrf` +value from that response as `X-CSRF-Token` on every `POST`, `PUT`, `PATCH` or +`DELETE`. It never sends an `Authorization` header. + +```js +const session = await (await fetch('/bff/session')).json(); +if (!session.authenticated) location.assign('/bff/login?return_to=' + encodeURIComponent(location.pathname)); + +await fetch('/api/reports', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': session.csrf }, + body: JSON.stringify(report), +}); +``` + +What the package guarantees (a request without a valid session gets 401 and is +never forwarded; CSRF is checked in constant time; only a refresh Socrate +rejects ends a session) is listed in the README's +[`bff` reference](../README.md#bff). For more than one BFF instance, replace +`MemoryStore` and the `pending` map with a shared store; `Session.Snapshot` and +`NewSessionFromSnapshot` serialise a session. +[`oauth2-admin/bff`](https://github.com/ovander/oauth2-admin/tree/main/bff) +is a complete BFF built this way, with logout and token revocation. + +Magic links fit the same model: the page the emailed link opens posts the +token to your BFF, which calls `client.VerifyMagicLink` and creates the session. + +### 7.2 Alternative: clients that hold their own tokens + +> ⚠️ **Use this only for mobile, native or CLI clients, or a legacy SPA not yet +> behind a BFF.** A client that holds tokens has to protect them itself. In a +> browser that is hard: any script running on the page — an XSS bug, a +> compromised dependency, a browser extension — can read tokens and PKCE state +> kept in memory, `sessionStorage` or `localStorage`, and a stolen refresh token +> keeps working until it is rotated or revoked. Keep access tokens short-lived, +> never put a client secret in the client, and plan the move to §7.1. + +Native apps should run the authorization request in the system browser and +receive the redirect on a claimed HTTPS link, a private-use URI scheme or a +loopback address (RFC 8252). The steps below show the protocol with browser +APIs. + +### 7.3 Authorization Code + PKCE in the client + +For a public client (no client secret). **Step 1 — generate a PKCE verifier/challenge and redirect to Socrate:** @@ -521,11 +716,12 @@ const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); const challenge = base64url(digest); +// Legacy SPA only: sessionStorage is readable by any script on the page. sessionStorage.setItem('pkce_verifier', verifier); const state = base64url(crypto.getRandomValues(new Uint8Array(16))); sessionStorage.setItem('oauth_state', state); -const url = new URL('https://auth.example.com/oauth/authorize'); +const url = new URL('https://socrate.example.com/oauth/authorize'); url.search = new URLSearchParams({ response_type: 'code', client_id: 'YOUR_CLIENT_ID', @@ -547,7 +743,7 @@ if (params.get('state') !== sessionStorage.getItem('oauth_state')) { throw new Error('state mismatch — possible CSRF'); } -const res = await fetch('https://auth.example.com/oauth/token', { +const res = await fetch('https://socrate.example.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ @@ -571,7 +767,7 @@ new URLSearchParams({ }); ``` -### 7.2 Direct JSON login (first-party only) +### 7.4 Direct JSON login (first-party only) For your *own* trusted frontends, Socrate exposes a JSON auth API on the public port — no redirect dance. Only use this for apps you own end-to-end. @@ -586,19 +782,20 @@ POST /api/auth/request-password-reset {email} POST /api/auth/reset-password {token, new_password} ``` -These endpoints are rate-limited per IP. See `go-oauth2/docs/API.md` §5 for full -shapes. +These endpoints are rate-limited per IP. Their request and response shapes are +documented in the Socrate server repository. -### 7.3 Magic link (passwordless) +### 7.5 Magic link (passwordless) Magic-link **send** is backend-only (M2M) — your frontend asks *your backend*, -which calls `client.SendMagicLink`. The user clicks the emailed link, and your -frontend completes it: +which calls `client.SendMagicLink`. The user clicks the emailed link, and the +page it opens completes the login. With a BFF, that page posts the token to the +BFF (§7.1). A client that holds its own tokens posts it to Socrate: ```js // User landed on your magic-link page with ?token=...&client_id=... in the URL. // Verify is POST-only (a GET would let email scanners burn the single-use token). -const res = await fetch('https://auth.example.com/api/auth/magic-link/verify', { +const res = await fetch('https://socrate.example.com/api/auth/magic-link/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -609,9 +806,9 @@ const res = await fetch('https://auth.example.com/api/auth/magic-link/verify', { const { access_token, refresh_token, id_token } = await res.json(); ``` -### 7.4 Calling your backend +### 7.6 Calling your backend with a bearer token -Once you hold an `access_token`, every call to *your* backend carries it: +A client that holds an `access_token` sends it on every call to *your* backend: ```js await fetch('https://api.example.com/me', { @@ -620,7 +817,7 @@ await fetch('https://api.example.com/me', { ``` Your backend's `jwtauth.Middleware` validates it and your handlers read identity -from `ctxutil`. **The frontend never talks to the :8081 admin port** — route +from `ctxutil`. **The frontend never talks to Socrate's admin API port** — route admin/user-management actions through your backend. --- @@ -742,7 +939,113 @@ r.Group(func(r chi.Router) { --- -## 10. Recipes +## 10. Enforcing central policy decisions (pep) + +`httpware.RBAC` and `tiering.Gate` (§9) decide locally, from rules compiled into +your service. When the rule should live in Socrate instead — one declarative +policy over roles, user attributes, resource attributes and request context, +shared by every application — use `pep`, the enforcement point for Socrate's +policy decision point. + +Prerequisites: the client needs `ClientSecret` and `AppID` (`Decide` uses the +service-account token), and `pep` runs after `jwtauth`, because the user's own +access token is sent as the decision's subject. Socrate resolves the user — role, +attributes, role in this application, how and when they authenticated — so +nothing about the user is taken on your application's word. Your application +supplies the action, the resource and the request context. + +```go +package main + +import ( + "log" + "net/http" + "os" + + "github.com/go-chi/chi/v5" + "github.com/sirupsen/logrus" + + "github.com/ovander/backendkit/jwtauth" + "github.com/ovander/backendkit/pep" + "github.com/ovander/backendkit/socrate" +) + +type invoice struct { + ID string + Amount int + OwnerID string +} + +func loadInvoice(r *http.Request) invoice { return invoice{ID: chi.URLParam(r, "id")} } + +func main() { + logger := logrus.WithField("service", "billing") + + client, err := socrate.NewClient(socrate.ClientConfig{ + BaseURL: os.Getenv("SOCRATE_BASE_URL"), + ClientID: os.Getenv("SOCRATE_CLIENT_ID"), + ClientSecret: os.Getenv("SOCRATE_CLIENT_SECRET"), // Decide uses the service-account token + AppID: os.Getenv("SOCRATE_APP_ID"), // required by Decide + }) + if err != nil { + log.Fatal(err) + } + enf, err := pep.New(pep.Config{Decider: client, Logger: logger}) + if err != nil { + log.Fatal(err) + } + + auth := jwtauth.New(os.Getenv("SOCRATE_JWKS_URL"), os.Getenv("SOCRATE_ISSUER"), logger, + jwtauth.WithAudience(os.Getenv("SOCRATE_CLIENT_ID"))) + + r := chi.NewRouter() + r.Use(auth.Handler) // first: the user's own token is the decision's subject + + // Route level: one decision before the handler runs. + r.With(enf.Middleware(func(r *http.Request) (string, socrate.PolicyResource, bool) { + return "invoice.read", socrate.PolicyResource{Type: "invoice"}, true + })).Get("/invoices", func(w http.ResponseWriter, r *http.Request) { /* … */ }) + + // Object level: once the resource is loaded, with its attributes. + r.Post("/invoices/{id}/approve", func(w http.ResponseWriter, r *http.Request) { + inv := loadInvoice(r) + err := enf.Check(r.Context(), "invoice.approve", socrate.PolicyResource{ + Type: "invoice", ID: inv.ID, + Attributes: map[string]any{"amount": inv.Amount, "owner_id": inv.OwnerID}, + }, pep.ContextFor(r)) + if pep.WriteDenial(w, err) { // 403 policy_denied, 403 mfa_required, 503 policy_unavailable… + return + } + // … approve + }) + + log.Fatal(http.ListenAndServe(":8080", r)) +} +``` + +What happens to a denial depends on the mode Socrate reports with each decision, +set centrally by its `POLICY_MODE`: `off` ignores it, `shadow` logs +`pep: policy would deny` and lets the request through, `enforce` refuses it. So +you can deploy the checks in `shadow`, read the would-deny lines, and switch to +`enforce` in Socrate without redeploying. + +Your frontend sees these error codes, in the `{"error": ""}` shape: + +| Status | `error` | Meaning | +|--------|---------|---------| +| 401 | `unauthenticated` | no user token in context (`pep` mounted before `jwtauth`) | +| 403 | `policy_denied` | the policy refused the action (`enforce` mode) | +| 403 | `elevation_required` | the policy requires a recent sign-in; re-authenticate | +| 403 | `mfa_required` | the policy requires multi-factor authentication | +| 503 | `policy_unavailable` | Socrate could not be asked and the mode is `enforce`, or not yet known | + +The README's [`pep` reference](../README.md#pep) details obligations, the +behaviour when Socrate is unreachable (`FailOpenWhenModeUnknown`), `CheckAsApp` +for background jobs and the `OnDecision` metrics hook. + +--- + +## 11. Recipes ### Onboard a teammate from your backend (M2M) @@ -800,26 +1103,30 @@ if err != nil || !res.Active { --- -## 11. Quick reference +## 12. Quick reference ### Method → endpoint → auth → port +`OAuth` is Socrate's public OAuth/OIDC port; `Admin` is its internal admin API +port (8081 in the default deployment). + | Client method | HTTP | Auth | Port | |---------------|------|------|------| -| `GetCurrentUserProfile` | `GET /oauth/userinfo` | JWT | 8080 | -| `IntrospectToken` | `POST /oauth/introspect` | creds | 8080 | -| `RevokeToken` | `POST /oauth/revoke` | creds | 8080 | -| `ListUsers` / `GetUser` / `CreateUser` | `…/api/apps/{id}/users` | JWT | 8081 | -| `UpdateUserRole` / `DeleteUser` | `…/api/apps/{id}/users/{uid}` | JWT | 8081 | -| `ResendVerification` / `ForcePasswordReset` | `…/users/{uid}/…` | JWT | 8081 | -| `RegisterUser` / `GetUserAsService` | `…/api/apps/{id}/users…` | M2M | 8081 | -| `InviteUserAsService` | `POST …/api/apps/{id}/service/users` | M2M | 8081 | -| `SendMagicLink` | `POST …/api/apps/{id}/service/magic-link` | M2M | 8081 | -| `ListApps` … `RotateSecret` | `…/api/admin/apps…` | JWT (admin) | 8081 | -| `AdminListUsers` … `RevokeUserTokens` | `…/api/admin/users…` | JWT (superadmin) | 8081 | -| `*Superadmin*` | `…/api/admin/superadmins…` | JWT (superadmin) | 8081 | -| `GetThreatMetrics` … `GetIPReputation` | `…/api/admin/security…` | JWT (admin) | 8081 | -| `GetDashboard*` / `*AdminLog*` | `…/api/admin/dashboard|logs…` | JWT (admin) | 8081 | +| `GetCurrentUserProfile` | `GET /oauth/userinfo` | JWT | OAuth | +| `IntrospectToken` | `POST /oauth/introspect` | creds | OAuth | +| `RevokeToken` | `POST /oauth/revoke` | creds | OAuth | +| `ListUsers` / `GetUser` / `CreateUser` | `…/api/apps/{id}/users` | JWT | Admin | +| `UpdateUserRole` / `DeleteUser` | `…/api/apps/{id}/users/{uid}` | JWT | Admin | +| `ResendVerification` / `ForcePasswordReset` | `…/users/{uid}/…` | JWT | Admin | +| `RegisterUser` / `GetUserAsService` | `…/api/apps/{id}/users…` | M2M | Admin | +| `InviteUserAsService` | `POST …/api/apps/{id}/service/users` | M2M | Admin | +| `SendMagicLink` | `POST …/api/apps/{id}/service/magic-link` | M2M | Admin | +| `ListApps` … `RotateSecret` | `…/api/admin/apps…` | JWT (admin) | Admin | +| `AdminListUsers` … `RevokeUserTokens` | `…/api/admin/users…` | JWT (superadmin) | Admin | +| `*Superadmin*` | `…/api/admin/superadmins…` | JWT (superadmin) | Admin | +| `GetThreatMetrics` … `GetIPReputation` | `…/api/admin/security…` | JWT (admin) | Admin | +| `GetDashboard*` / `*AdminLog*` | `…/api/admin/dashboard…`, `…/api/admin/logs…` | JWT (admin) | Admin | +| `Decide` | `POST …/api/apps/{id}/service/policy/decide` | M2M | Admin | ### Roles (highest → lowest privilege) @@ -831,7 +1138,7 @@ if err != nil || !res.Active { --- -## 12. Gotchas & FAQ +## 13. Gotchas & FAQ **"no JWT in context" error from a client method.** A mode-A (JWT-forwarding) method ran without a token in context. Inside a handler, ensure `auth.Handler` @@ -855,10 +1162,18 @@ causes: wrong `SOCRATE_ISSUER` (issuer mismatch), clock skew (expired), or the JWKS URL pointing at the wrong environment. Check `jwtauth` logs — it logs the validation failure reason. -**Should the frontend ever call the :8081 admin port?** No. It's internal. +**Should the frontend ever call Socrate's admin API port?** No. It's internal +(8081 in the default deployment). Proxy every admin/user-management action through your backend so you can apply your own authorization first. +**The BFF answers 403 on a `POST`.** The request lacks the session's CSRF +token. Read `csrf` from your session endpoint and send it as `X-CSRF-Token` on +every unsafe method (§7.1). + +**Every `pep` check answers 401.** `pep` runs before `jwtauth`, so there is no +user token in context. Mount `auth.Handler` first (§10). + **Is the client safe to share across goroutines?** Yes. Construct one at startup and reuse it; the service-account token cache is mutex-guarded. @@ -867,5 +1182,5 @@ and reuse it; the service-account token cache is mutex-guarded. ### See also - [`README.md`](../README.md) — package-by-package reference for all of backendkit. -- [`go-oauth2/docs/API.md`](https://github.com/ovander/go-oauth2/blob/main/docs/API.md) — the canonical server-side HTTP API reference. +- The Socrate server repository (`ovander/go-oauth2`, not public yet) documents the raw HTTP API. - Go API docs: