From 6977662e80955215cc295fbd1a154e9b5c6fe5e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 17:24:44 +0000 Subject: [PATCH 1/2] docs: cover bff and pep in the README and the integration guide README: pitch, Why and a redrawn architecture diagram now include bff and pep; the httpware table lists Metrics / MetricsHandler; requirements no longer claim ctxutil needs Socrate; package sections are tightened and link to pkg.go.dev; "Production usage" becomes "Used by" with checkable repositories; Contributing and License follow the suite standard. Integration guide: OAuth 2.1 / OpenID Connect wording, new BFF (7.1) and pep (10) sections with examples compiled against the module, browser apps steered to the BFF with client-held tokens kept as a labelled alternative, and the admin port described as 8081 in the default deployment. Links to the private server repository are plain text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA --- README.md | 764 +++++++++++++++++++------------------ docs/CLIENT-INTEGRATION.md | 519 ++++++++++++++++++++----- 2 files changed, 816 insertions(+), 467 deletions(-) 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/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: From dd648a85c55cc1c54c2241b822319f105c2759df Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 17:24:45 +0000 Subject: [PATCH 2/2] docs: align SECURITY, CONTRIBUTING, CHANGELOG and GitHub templates SECURITY.md states the controls instead of review IDs and names the server repository in plain text. CONTRIBUTING.md drops the private link and completes the pull-request steps. The PR template checklist is the full local gate, including govulncheck. The issue config keeps only the security contact. CHANGELOG.md gets the standard header, plain wording for review documents that do not exist in the tree, version link references, and an Unreleased entry for this documentation change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA --- .github/ISSUE_TEMPLATE/config.yml | 3 -- .github/pull_request_template.md | 21 +++++---- CHANGELOG.md | 77 ++++++++++++++++--------------- CONTRIBUTING.md | 23 +++++---- SECURITY.md | 23 +++++++-- 5 files changed, 86 insertions(+), 61 deletions(-) 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/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.