Shared Go library for backend services that use Socrate as their OAuth2/OIDC provider.
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.
backendkit packages that foundation into a single, versioned dependency so every service starts from the same production-grade baseline:
- 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 —
tieringgives you a plan hierarchy, HTTP middleware gates, and per-feature policy rules backed by Postgres. - Socrate API client — a single
socrate.Clientcovers 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).
- Go 1.22 or later
- 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, andctxutilwill not function correctly. - A PostgreSQL database is required if you use
tiering.PolicyServicefor persistent feature policies.
go get github.com/ovander/backendkit@v1.4.0// go.mod
module github.com/your-org/my-service
go 1.22
require github.com/ovander/backendkit v1.4.0The 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)
}| 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, RBAC |
gormlogger |
GORM → logrus bridge with slow-query detection |
jwtauth |
JWT RS256 validation middleware with JWKS cache and stale-key fallback |
socrate |
Full Socrate API client (user CRUD, service-account token, magic links, invite) |
tiering |
Plan registry, tier gate middleware, feature policy model and service |
aigateway |
Multi-provider AI client (OpenAI + Claude), ExtractJSONInto |
ainarration |
Generic LRU+TTL narration cache and CacheKey helper |
pagination |
Query-param parsing and PagedResponse |
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) │
└────────────────────────────────────────────┘
The following bootstraps a production chi router with the complete middleware stack, plan-based routing, and enterprise-only admin routes.
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() {
log := logrus.WithField("service", "my-service")
// 1. Auth middleware — validates RS256 JWT, injects claims into context.
auth := jwtauth.New(
os.Getenv("SOCRATE_JWKS_URL"),
os.Getenv("SOCRATE_ISSUER"),
log,
)
// 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(log))
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)
})
}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.
user, err := repo.GetByID(id)
if errors.Is(err, gorm.ErrRecordNotFound) {
apierror.NotFound("user", id).WriteJSON(w)
return
}
apierror.Internal("database error").WriteJSON(w)Available constructors: NotFound, BadRequest, Unauthorized, Forbidden, Conflict, ValidationError, Internal, ServiceUnavailable.
Typed helpers for every Socrate JWT claim that jwtauth injects into the context. Every Get* function is safe to call even when the value is absent — they return zero values (or "freemium" for plan).
tenantID, ok := ctxutil.GetTenantID(ctx) // uuid.UUID
userID, ok := ctxutil.GetUserID(ctx) // uuid.UUID
role := ctxutil.GetUserRole(ctx) // string
plan := ctxutil.GetUserPlan(ctx) // string — defaults to "freemium"
email := ctxutil.GetUserEmail(ctx)
name := ctxutil.GetUserName(ctx)
requestID := ctxutil.GetRequestID(ctx)
logger := ctxutil.GetLogger(ctx) // *logrus.Entry — falls back to standard loggerAll 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(entry) |
| Security headers | httpware.SecurityHeaders |
| Body size limit | httpware.BodyLimit(bytes) |
| Panic recovery | httpware.Recover(logger) |
| Per-route timeout | httpware.Timeout(d) |
| Per-tenant rate limit | httpware.NewRateLimiter(rps, burst) |
| Role-based access | httpware.NewRBAC(roleMap, logger) |
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)
})Bridges GORM's internal logger to logrus. Slow queries (configurable threshold) are logged at Warn; ErrRecordNotFound is demoted to Debug to avoid log noise in normal operation.
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: gormlogger.New(
log.WithField("component", "db"),
gormlogger.Config{
SlowThreshold: 200 * time.Millisecond,
IgnoreNotFound: true,
},
),
})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_credentialsservice-account flow, magic-link endpoint). It 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.
AppIDis required for all service-account methods. Service-account tokens carrysub=app:{id}and the Socrate admin routes cannot resolve the app ID at runtime without it. Always setAppIDinClientConfig; omitting it causes an immediate error on the first service-account call (InviteUserAsService,RegisterUser,GetUserAsService,SendMagicLink).
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
}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.
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": "<raw>", "client_id": "<client_id>"}
//
// On success Socrate returns an access_token, refresh_token, and id_token.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://auth.example.com/.well-known/jwks.json",
"https://auth.example.com",
logger,
)
r.Use(auth.Handler)
// Downstream handlers read claims without importing jwtauth:
tenantID, _ := ctxutil.GetTenantID(r.Context())
plan := ctxutil.GetUserPlan(r.Context())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: {"error":"plan_required","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.
// Seed baseline rules at startup:
svc.SeedDefaults([]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("ai_narration", plan)
limit := svc.NumericLimit("export_limit", plan)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)
// Parse a JSON object embedded in an AI prose response:
var data MyStruct
err = aigateway.ExtractJSONInto(result, &data)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.
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: capacity = 1000 entries, TTL = 15 min
// 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},
})Query-parameter parsing for page and pageSize, with defaults and upper-bound clamping. Returns a PagedResponse envelope for consistent list API shapes.
params := pagination.Parse(r) // page=1, pageSize=20 by default
offset := params.Offset() // (page-1) * pageSize
resp := pagination.NewPagedResponse(items, total, params)
// {"data": [...], "page": 1, "pageSize": 20, "totalCount": 142}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.
The library follows a conservative compatibility policy: no breaking changes within a major version.
backendkit follows Semantic Versioning:
- 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.
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- Add your package under its own directory with a package-level doc comment.
- Write table-driven tests; place
_test.gofiles in the same package directory. - Add runnable examples in
example_test.go— they appear on pkg.go.dev. - All exported symbols must have Go doc comments that begin with the symbol name.
- Run
go test -race ./...andgo vet ./...before opening a PR. - Keep packages decoupled — the only allowed cross-package imports within the library are
ctxutilandapierror(shared primitives). All other cross-package imports are prohibited.