Skip to content

Repository files navigation

backendkit

Go Reference CI Latest tag License: Apache-2.0 Go version

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.


Table of contents


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.

Packages

Package Purpose
apierror Structured HTTP error types (AppError, constructor functions)
ctxutil Typed context keys for Socrate claims (tenant, user, role, plan, logger, request-id)
httpware Chi-compatible middlewares: RequestID, Logger, SecurityHeaders, BodyLimit, Recover, Timeout, RateLimiter, RequireTenant, RBAC, Metrics (Prometheus RED)
gormlogger GORM → logrus bridge with slow-query detection
jwtauth JWT RS256 validation middleware with JWKS cache and stale-key fallback
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 API client: token flows, user CRUD, service-account token, magic links, invites, app and superadmin management, security monitoring, dashboard, audit logs, policy decisions
tiering Plan registry, tier gate middleware, feature policy model and service
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 AI provider client (OpenAI and Claude; Ollama through NewAIClient), ExtractJSON / ExtractJSONInto
ailang Language guard for AI output: every response is in the requested locale (fr/en), with retry and translation fallback
ainarration Generic LRU+TTL narration cache and CacheKey helper
pagination Query-param parsing and PagedResponse
buildinfo Build-time version metadata (-ldflags) and a /version HTTP handler

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.

Which package do I need?

I want to… Use
Validate incoming Socrate JWTs and populate the request context jwtauth
Serve a browser SPA without ever giving it OAuth tokens (BFF) bff + socrate
Authorise actions with Socrate's central, declarative policy (RBAC + ABAC, object-level) pep
Read the tenant / user / role / plan of the current request ctxutil
Add request IDs, structured logging, panic recovery, timeouts, body limits, security headers httpware
Rate-limit per tenant httpware.RateLimiter
Expose Prometheus RED metrics keyed by route pattern (same metric scheme as Socrate) httpware.Metrics + httpware.MetricsHandler
Guarantee a tenant on tenant-scoped routes httpware.RequireTenant
Gate routes by role/permission httpware.RBAC
Gate routes or features by commercial plan tiering
Return consistent JSON errors apierror
Call Socrate to manage users, apps, tokens, or security socrate
Call OpenAI or Claude through one interface aigateway
Guarantee AI output is in the user's language ailang
Cache AI results to cut latency and cost ainarration
Parse ?page/?per_page and return paged lists pagination
Log GORM queries through logrus / flag slow queries gormlogger
Expose build/version info on a /version endpoint buildinfo

Requirements

  • 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.

Installation

go get github.com/ovander/backendkit@v1.15.0
// go.mod
module github.com/your-org/my-service

go 1.25

require github.com/ovander/backendkit v1.15.0

Quick start

The smallest working setup: JWT validation and a single protected route.

package main

import (
    "net/http"
    "os"
    "time"

    "github.com/go-chi/chi/v5"
    "github.com/sirupsen/logrus"

    "github.com/ovander/backendkit/httpware"
    "github.com/ovander/backendkit/jwtauth"
)

func main() {
    log := logrus.WithField("service", "my-service")

    auth := jwtauth.New(
        os.Getenv("SOCRATE_JWKS_URL"),
        os.Getenv("SOCRATE_ISSUER"),
        log,
    )

    r := chi.NewRouter()
    r.Use(httpware.RequestID)
    r.Use(httpware.Recover(log))
    r.Use(httpware.Timeout(30 * time.Second))
    r.Use(auth.Handler)

    r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("ok"))
    })

    http.ListenAndServe(":8080", r)
}

Full middleware stack

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 in front of this API; to authorise actions centrally, add pep.

package main

import (
    "os"
    "time"

    "github.com/go-chi/chi/v5"
    "github.com/sirupsen/logrus"

    "github.com/ovander/backendkit/httpware"
    "github.com/ovander/backendkit/jwtauth"
    "github.com/ovander/backendkit/tiering"
)

func main() {
    base := logrus.New()                          // *logrus.Logger — for httpware.Logger
    log := base.WithField("service", "my-service") // *logrus.Entry  — for everything else

    // 1. Auth middleware — validates RS256 JWT, injects claims into context.
    auth := jwtauth.New(
        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.
    rl := httpware.NewRateLimiter(20, 40)
    defer rl.Stop()

    // 3. Tier gate — uses the default freemium/pro/enterprise hierarchy.
    gate := tiering.NewGate(tiering.DefaultRegistry(), log, "/settings/billing")

    r := chi.NewRouter()

    // Global middleware (runs before auth).
    r.Use(httpware.RequestID)
    r.Use(httpware.Logger(base)) // takes *logrus.Logger, not *logrus.Entry
    r.Use(httpware.SecurityHeaders)
    r.Use(httpware.BodyLimit(4 * 1024 * 1024)) // 4 MB
    r.Use(httpware.Recover(log))
    r.Use(httpware.Timeout(30 * time.Second))

    // Auth + rate limit (after context is populated).
    r.Use(auth.Handler)
    r.Use(rl.Handler)

    // Public routes.
    r.Get("/healthz", healthHandler)

    // Pro-only routes.
    r.Group(func(r chi.Router) {
        r.Use(gate.Require(tiering.PlanPro))
        r.Post("/ai/narrate", narrateHandler)
    })

    // Enterprise-only routes.
    r.Group(func(r chi.Router) {
        r.Use(gate.Require(tiering.PlanEnterprise))
        r.Get("/admin/tenants", listTenantsHandler)
    })
}

The client integration guide 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 <JWT>       │ Authorization: Bearer <JWT>
                    ▼                                   ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 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.

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:

{"error": {"code": "not_found", "message": "user not found: 42"}}
user, err := repo.GetByID(id)
if errors.Is(err, gorm.ErrRecordNotFound) {
    apierror.NotFound("user", id).WriteJSON(w)
    return
}
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.

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:

// Identical envelope to the typed constructors, with the caller's status preserved:
apierror.Write(w, resp.StatusCode, "upstream rejected the request")

Two fluent helpers refine an error before it is written:

// WithKey attaches an i18n key the frontend can translate (added to the JSON as "key").
apierror.BadRequest("invalid store ID").WithKey("errors.invalidStoreId").WriteJSON(w)

// WithDetails attaches an arbitrary structured payload (serialised as "details").
apierror.ValidationError("validation failed", fieldErrors).WriteJSON(w)

Full API: pkg.go.dev/…/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.

tenantID  := ctxutil.GetTenantID(ctx)    // uuid.UUID — uuid.Nil when absent
tenantStr := ctxutil.GetTenantIDStr(ctx) // string — "" when absent (handy for logging)
userID    := ctxutil.GetUserID(ctx)      // uuid.UUID — uuid.Nil when absent
sub       := ctxutil.GetUserSub(ctx)     // string — raw Socrate subject (e.g. "42")
role      := ctxutil.GetUserRole(ctx)    // string
plan      := ctxutil.GetUserPlan(ctx)    // string — defaults to "freemium"
email     := ctxutil.GetUserEmail(ctx)   // string (ID-token flows only)
name      := ctxutil.GetUserName(ctx)    // string (ID-token flows only)
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), 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.

Full API: pkg.go.dev/…/ctxutil.


httpware

All middleware functions follow the standard func(http.Handler) http.Handler signature and work with any net/http-based router.

Middleware Constructor
Request ID httpware.RequestID
Structured logger httpware.Logger(logger) — takes a *logrus.Logger
Security headers httpware.SecurityHeaders
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) — 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.

RBAC — defining permissions:

const (
    PermReadReport  httpware.Permission = "read:report"
    PermWriteReport httpware.Permission = "write:report"
)

rbac := httpware.NewRBAC(httpware.RoleMap{
    "viewer": {PermReadReport},
    "editor": {PermReadReport, PermWriteReport},
}, logger)

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:

r.Use(httpware.Timeout(10 * time.Second)) // global default

r.Group(func(r chi.Router) {
    r.Use(httpware.Timeout(120 * time.Second)) // replaces the 10 s deadline
    r.Post("/export/pdf", exportPDF)
})

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:

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.


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.

New takes positional arguments — New(entry, level, slowThreshold, ignoreNotFound) — where level is a gorm.io/gorm/logger.LogLevel:

import glogger "gorm.io/gorm/logger"

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: gormlogger.New(
        log.WithField("component", "db"), // *logrus.Entry
        glogger.Warn,                     // minimum level (Silent/Error/Warn/Info)
        200*time.Millisecond,             // slow-query threshold; 0 disables
        true,                             // demote ErrRecordNotFound to Debug
        gormlogger.WithSQLRedaction(),    // production: keep SQL values out of logs
    ),
})

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.


socrate

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.

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).

client, err := socrate.NewClient(socrate.ClientConfig{
    BaseURL:      os.Getenv("SOCRATE_BASE_URL"),
    ClientID:     os.Getenv("SOCRATE_CLIENT_ID"),
    ClientSecret: os.Getenv("SOCRATE_CLIENT_SECRET"),
    AppID:        os.Getenv("SOCRATE_APP_ID"), // required for service-account calls
})

// User-scoped — attach the caller's raw JWT first:
ctx = socrate.WithJWT(ctx, rawJWT)
users, err := client.ListUsers(ctx, "", 1, 20)
user, err  := client.GetUser(ctx, userID)

// Service-account — token acquired and cached automatically:
inv, err := client.InviteUserAsService(ctx, socrate.ServiceInviteRequest{
    Email: "new@example.com",
    Role:  "editor",
})

// Conflict handling:
if errors.Is(err, socrate.ErrUserAlreadyExists) {
    // handle duplicate registration
}

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 explains the auth modes and port routing and has the method reference, with the auth mode and port of each call.

Client attribution

A BFF calls Socrate's token and revoke endpoints server-to-server, so Socrate would audit a login, refresh or logout as the BFF (127.0.0.1, Go-http-client/1.1). Put the browser's address and User-Agent on the context and the calls made on a user's behalf — ExchangeCode, RefreshToken, RevokeToken, VerifyMagicLink, AdminLogin, Logout — send them as X-Forwarded-For and User-Agent. Service-account calls (the client_credentials grant), IntrospectToken and GetCurrentUserProfile never do. Without attribution on the context nothing changes.

// ip comes from YOUR resolver (trust X-Forwarded-For only from your own edge proxy),
// never from a raw request header.
ctx := socrate.WithClientAttribution(r.Context(), socrate.ClientAttribution{
    IP:        ip,
    UserAgent: r.UserAgent(),
})
ts, err := client.ExchangeCode(ctx, code, redirectURI, verifier)

// A BFF building its own token/revoke requests applies it itself:
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, body)
socrate.ApplyClientAttribution(req)
Symbol Purpose
ClientAttribution{IP, UserAgent} The browser a call is made for
WithClientAttribution(ctx, a) / ClientAttributionFrom(ctx) Store / read it on a context
ApplyClientAttribution(req) Set the headers on a request from its context

X-Forwarded-For is replaced with exactly the one address (and X-Real-IP removed), never appended to: Socrate takes the leftmost entry from a trusted proxy, so appending to a browser-supplied value would let the browser choose its logged address. An address that does not parse sends nothing; the User-Agent loses its control characters and is capped at 512 bytes. In a bff BFF, bff.WithClientAttribution sets this from the incoming request.

Full API: pkg.go.dev/…/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.

auth := jwtauth.New(
    "https://socrate.example.com/.well-known/jwks.json",
    "https://socrate.example.com",
    logger,
)
r.Use(auth.Handler)

// Downstream handlers read claims without importing jwtauth:
tenantID := ctxutil.GetTenantID(r.Context()) // uuid.Nil if the token carried no tenant_id
plan     := ctxutil.GetUserPlan(r.Context()) // "freemium" when absent

Two opt-in options harden it; new services should set the first:

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")
        }
        return nil
    }))
  • 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.

Full API: pkg.go.dev/…/jwtauth.


bff

The runtime of a Backend-for-Frontend. In a BFF the browser never holds OAuth tokens: the BFF is the confidential client, runs Authorization Code + PKCE server-side, keeps the tokens in a server-side session and gives the browser only an opaque HttpOnly cookie. The token calls themselves come from the socrate package — *socrate.Client is the gateway's refresher. The Socrate admin and monitoring consoles run on this package.

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
}

// 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)))

// Optional: attribute token calls to the browser; serve this instead of mux.
// clientIP is your own resolver (X-Forwarded-For only from your edge proxy).
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	mux.ServeHTTP(w, bff.WithClientAttribution(r, clientIP(r)))
})
log.Fatal(http.ListenAndServe("127.0.0.1:8080", handler))

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. oauth2-admin/bff is a complete BFF built this way.

Safe by default.

Concern Behaviour
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, 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 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
Token-call attribution Opt-in: WithClientAttribution(r, ip) puts the browser's address (as your resolver found it) and User-Agent on the request context, so the code exchange, the gateway's refresh and revocation are audited by Socrate as the browser rather than as the BFF (see client attribution)

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. A Gateway must be used by pointer and never copied.

Full API: pkg.go.dev/…/bff.


pep

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:

client, _ := socrate.NewClient(socrate.ClientConfig{ /* BaseURL, ClientID, ClientSecret, AppID */ })
enf, _ := pep.New(pep.Config{Decider: client, Logger: logger})

r.Use(auth.Handler) // jwtauth first: the user's own token is the decision's subject

// Route-level: one decision before the handler.
r.With(enf.Middleware(func(r *http.Request) (string, socrate.PolicyResource, bool) {
    return "invoice.read", socrate.PolicyResource{Type: "invoice"}, true
})).Get("/invoices", listInvoices)

For object-level checks inside a handler (Enforcer.Check with the loaded resource's attributes, then pep.WriteDenial), see the client integration guide.

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:

Mode A denial…
off is ignored
shadow is logged (pep: policy would deny) and the request proceeds
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. 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.


tiering

Three components that work together for plan-based feature gating.

PlanRegistry — an ordered plan hierarchy with tier comparison:

reg := tiering.DefaultRegistry() // freemium < pro < enterprise

reg.TierAtLeast("pro", "freemium") // true
reg.TierAtLeast("freemium", "pro") // false
reg.Normalise("UNKNOWN")           // "freemium" (lowest tier)

// Custom hierarchy:
reg = tiering.NewPlanRegistry("starter", "growth", "enterprise")

Gate — HTTP middleware that rejects requests below a plan threshold with a structured JSON error:

gate := tiering.NewGate(tiering.DefaultRegistry(), logger, "/billing")

r.With(gate.Require(tiering.PlanPro)).Post("/ai/narrate", handler)
// Freemium users receive 403 with the standard error envelope:
// {"error":{"code":"upgrade_required",
//           "message":"This feature requires the pro plan or above",
//           "details":{"plan":"freemium","requiredPlan":"pro","upgradeUrl":"/billing"}}}

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.

// Seed baseline rules at startup:
svc.SeedDefaults(ctx, []tiering.FeaturePolicy{
    {
        Feature: "ai_narration", Category: "ai", Label: "AI Narration",
        FeatureType: tiering.FeatureTypeAccess,
        Freemium:    tiering.MarshalAccess(false),
        Pro:         tiering.MarshalAccess(true),
        Enterprise:  tiering.MarshalAccess(true),
    },
    {
        Feature: "export_limit", Category: "exports", Label: "Monthly Exports",
        FeatureType: tiering.FeatureTypeNumericLimit,
        Freemium:    tiering.MarshalLimit(5),
        Pro:         tiering.MarshalLimit(50),
        Enterprise:  tiering.MarshalLimit(-1), // -1 = unlimited
    },
})

// In a handler:
plan    := ctxutil.GetUserPlan(ctx)
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.


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.

ai := aigateway.New(aigateway.Config{
    Provider:   "claude",  // "claude" or "openai"
    APIKey:     os.Getenv("ANTHROPIC_API_KEY"),
    Model:      "claude-sonnet-4-6",
    MaxTokens:  2000,
    TimeoutSec: 30,
}, logger)

result, err := ai.Call(ctx, prompt)

// Override the token ceiling for a single call:
result, err = ai.CallWithMaxTokens(ctx, prompt, 4000)

// Parse a JSON object embedded in an AI prose response:
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. NewAIClient builds a language-safe client (wrapped with 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.

Full API: pkg.go.dev/…/aigateway.


ailang

A language guard for AI output: every AIResponse.Text is in the requested locale (fr or en). It prepends a language directive to the prompt, checks the answer with a fast stopword heuristic, retries once with a reinforced prompt, and as a last resort translates the answer with the same model.

guard := ailang.New(aiClient, ailang.DefaultAIConfig(), nil, logger) // aiClient: *aigateway.Client; nil reporter = no-op

resp, err := guard.Generate(ctx, ailang.PromptInput{
    Prompt:   "Explique les résultats du plan.",
    Locale:   "fr",
    Metadata: map[string]any{"module": "insight"},
})

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.


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.

cache := ainarration.NewNarrationCache(ainarration.DefaultCacheConfig())
// DefaultCacheConfig: MaxSize = 200 entries, TTL = 2 h

// Same inputs always produce the same key (content-addressed):
key := ainarration.CacheKey("plan_narration", userRole, myContextStruct)

if out, ok := cache.Get(tenantID, key); ok {
    return out.Narrative // served from cache
}

text, _ := ai.Call(ctx, prompt)
cache.Put(tenantID, key, &ainarration.NarrationOutput{
    Narrative: text,
    Metadata:  map[string]any{"model": "claude-sonnet-4-6", "latency_ms": 340},
})

Full API: pkg.go.dev/…/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.

params := pagination.Parse(r)   // reads ?page & ?per_page; page=1, perPage=20 by default
offset := params.Offset         // field (not a method): (page-1) * perPage

resp := pagination.NewPagedResponse(items, params, total) // (data, params, totalItems)
// {"data": [...], "page": 1, "perPage": 20, "totalItems": 142, "totalPages": 8}

Full API: pkg.go.dev/…/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.

LDFLAGS := \
    -X github.com/ovander/backendkit/buildinfo.Version=$(VERSION) \
    -X github.com/ovander/backendkit/buildinfo.BuildTime=$(BUILD_TIME) \
    -X github.com/ovander/backendkit/buildinfo.GitCommit=$(GIT_COMMIT)
// Mount on an unauthenticated route so monitoring tools can read it tokenless.
r.Get("/api/v1/version", buildinfo.Handler())

// Or read the struct directly (adds GoVersion from runtime.Version()):
info := buildinfo.Get()
// {"version":"v1.2.3","buildTime":"...","gitCommit":"a1b2c3d","goVersion":"go1.25"}

Full API: pkg.go.dev/…/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, e.g. http://127.0.0.1:8081 (the server's ADMIN_PORT; 8082 where a legacy server holds 8081). Set it in production: the value derived from SOCRATE_BASE_URL (port 8081, same scheme and host) is wrong behind a TLS proxy
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:

import (
    "net/http/httptest"

    "github.com/ovander/backendkit/ctxutil"
    "github.com/ovander/backendkit/tiering"
)

req := httptest.NewRequest(http.MethodGet, "/ai/narrate", nil)
ctx := req.Context()
ctx = ctxutil.WithUserPlan(ctx, tiering.PlanPro) // pretend a pro user
ctx = ctxutil.WithUserRole(ctx, "editor")
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:

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte(`{"content":[{"type":"text","text":"hello"}]}`)) // mock Claude response
}))
defer srv.Close()

ai := aigateway.ClientForTest("claude", "test-key", srv.URL)
out, _ := ai.Call(context.Background(), "ping") // → "hello"

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) that double as usage docs.


Troubleshooting

Symptom Likely cause & fix
Every request returns 401 No Authorization: Bearer <token> 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 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.

Used by

  • ovander/oauth2-admin — the Socrate superadmin console; its BFF (bff/) is built on bff and socrate.
  • ovander/oauth2-monitoring — the Socrate security monitoring console; its BFF (bff/) is built on bff and socrate.
  • 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, 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).

Always pin an explicit version in go.mod rather than using @latest to keep builds reproducible.


Status

backendkit is in active use as part of the Socrate suite. Current focus:

  • 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.

Contributing

See CONTRIBUTING.md for setup, the checks CI runs, and the pull-request workflow; changes are listed in CHANGELOG.md. Report vulnerabilities privately as described in SECURITY.md.


License

Copyright © 2026 Olivier Vandermoten. Licensed under the Apache License, Version 2.0; see LICENSE. SPDX-License-Identifier: Apache-2.0

About

Shared Go library for services that sign users in with Socrate (OAuth 2.1 / OIDC): JWT validation, a Backend-for-Frontend runtime, central policy enforcement, HTTP middleware.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages