Architecture
nauthera-server is one static Go binary (CGO_ENABLED=0) that runs from a scratch
image. A thin main parses flags and configuration and hands off to a composition root,
which wires storage, caching, keys, observability and the three servers, and runs the
Kubernetes-ordered shutdown sequence.
Three listeners
Different kinds of traffic are kept on separate sockets, and each socket has its own exposure and hardening:
| Listener | Default | Serves | Exposure |
|---|---|---|---|
| Public HTTP | :8080 | OAuth 2.0, OpenID Connect, the hosted sign-in UI and its /v1/auth/* API, the admin dashboard at /admin/ and the /v1/admin/* API | Public, behind an ingress or load balancer |
| Admin HTTP | :9090 | /metrics, /healthz (liveness), /readyz (readiness), /startupz (startup), /debug/pprof/* only when server.admin.pprof is set | Internal only, never expose it |
| gRPC | :9091 | grpc.health.v1 and nauthera.admin.v1.AdminService | Internal. Mutual TLS, or plaintext only with server.grpc.allow_insecure: true on a loopback address; the chart's production profile turns it off unless you run the operator |
A contract-first HTTP surface
Nearly every endpoint is declared in the OpenAPI 3.2 document
api/openapi/nauthera.yaml. An in-repo generator turns it into the routing, request
validation, tracing, per-operation caching and rate limiting (x-cache, x-rate-limit),
and typed response objects. Handlers receive a decoded, validated request and return a
typed response; they never touch the ResponseWriter. CI fails if the generated code
drifts from the spec.
The document also declares the permission each admin operation requires
(x-permission), and a test holds each handler to the scope and permission it declares.
It declares the longest time window the audit list, the audit counts and a user's session
history accept (x-window, 90 days) and the closed list of problem codes the server sends
(ProblemCode). The generator emits this metadata as TypeScript as well, in
web/packages/api/src/contract.gen.ts, which the
admin dashboard reads.
A few routes are mounted by hand. Each organization's discovery documents and JWKS under
/orgs/{slug}/.well-known/, and its discovery documents at the RFC 8414 path-inserted
locations, are built from that organization's runtime, so one organization's keys are
never cached under another's path. The others are the sign-in UI at the root, the admin
dashboard at /admin/, a version banner at /healthz on the public listener, and a JSON
404 for unknown /v1/ paths.
Every route in the document is served twice: at the root for the main organization
and under /orgs/{slug}/… for every other organization. See
Organizations.
The hosted sign-in UI and the admin dashboard
Two React single-page apps built with Vite are compiled into the binary with go:embed.
- The sign-in UI is served from the public listener's root (
/login,/consent,/device,/forgot-password,/reset-password,/verify-emailand so on). The server injects the requesting client's branding into the page when it serves it. See Sign-in Experience. - The admin dashboard is served at
/admin/for the main org and/orgs/{slug}/admin/for every other org, with the org's admin console theme. It signs in through the org's{slug}-admin-consoleclient with DPoP-bound tokens held in memory, and reads and writes only through the/v1/admin/*API. See Admin Dashboard.
Storage
Two pure-Go drivers are supported, selected by database.driver:
postgres(the default) throughjackc/pgx. Connect with adsn, or with discretehost,port,user,password,nameandsslmodecomponents, which match the Secrets that CloudNativePG and Zalando publish. Setdatabase.pgbouncer: truebehind PgBouncer transaction pooling. Time columns aretimestamptz.sqlitethroughmodernc.org/sqlite, for development, tests and single-node installs.
The migrations are embedded in the binary and written as one portable SQL set for both
databases. The runner applies each migration in its own transaction, verifies checksums,
and serializes concurrent replicas with a dialect-aware lock (a PostgreSQL advisory lock).
Turn it on with database.migrate: true.
Cache
A Redis-compatible cache (cache.*) holds what must be shared between replicas:
single-use authorization codes, rotating refresh tokens, and the brute-force throttles.
Any issuer that is not loopback requires it, and the server refuses to start without
it. A loopback issuer may run without it, and then these live in one process's memory.
Rate limiting uses memory or Redis, selected by rate_limit.backend.
cache.mode is standalone (one cache.address), sentinel (cache.addresses and
cache.master_name) or cluster (cache.addresses). The server refuses a Redis that may
evict keys: maxmemory-policy must be noeviction unless no memory limit is set. With
cache.replica_acks set, a write counts only once that many replicas have acknowledged
it, within cache.replica_ack_timeout. Commands that consume a code, claim a token or
bump a counter are never sent twice.
Password reset links, email verification links and new-sign-in alerts are sent through
SMTP. The deployment's relay is mail.smtp.*, and an org listed in mail.organizations
sends through its own, with credentials read from files at every delivery. A relay
needs TLS (starttls or implicit); none must be chosen explicitly. Messages are
branded and localized, with an HTML and a text part.
Nothing touches SMTP on the request path. A message is sealed under the key-encryption key and written to a durable outbox in the database. Every replica runs a worker that claims due messages with an expiring lease, so a message is delivered from one replica and a replica that stops loses nothing. Transient failures are retried with capped backoff; the body is dropped once the message is sent or has failed for good.
The control plane
The gRPC AdminService is the declarative write path for clients, branding and
organizations. The Kubernetes operator drives it; nauthera-cli uses it for local
development.
| RPCs | Status |
|---|---|
GetServerInfo | Implemented |
UpsertOIDCClient, GetOIDCClient, DeleteOIDCClient, ListOIDCClients | Implemented |
UpsertBranding, GetBranding, DeleteBranding, ListBrandings | Implemented |
CreateOrganization, GetOrganization, ListOrganizations, DeleteOrganization | Implemented |
UpsertAuthPolicy, DeleteAuthPolicy | Return Unimplemented |
- Upserts are idempotent full replaces and carry an
etagfor optimistic concurrency, so a controller can tell its own write from someone else's. - Client secrets never travel in the spec. When asked to, the server generates a secret, stores only its SHA-256 digest, and returns the plaintext once in the upsert response.
- Ownership. Each resource belongs to the verified identity that wrote it, and reads, lists and deletes see only the caller's own resources. The org a client belongs to is decided on the server, from its issuer.
- Transport. TLS 1.3 with a verified client certificate.
server.grpc.tls.allowed_clientslists each admitted identity, matched by SAN type (dns:<name>oruri:<uri>, never the Common Name), and the operations it may call (organizations,clients,brandings). Plaintext needs an explicitserver.grpc.allow_insecure: trueand a loopbackserver.grpc.address, for local stacks only.
The operator side is described in Operator.
Lifecycle
- Probes.
/healthz,/readyzand/startupzon the admin listener, as the chart wires them.grpc.health.v1is also served on:9091, but Kubernetes' native gRPC probe connects without TLS, so it works only while that listener runs without TLS. The binary also has ahealthchecksubcommand for images with no shell. - Graceful shutdown. On
SIGTERMthe server marks itself not ready, waitsserver.drain_delay(5s) so endpoints stop routing to it, then drains in-flight requests for up toserver.shutdown_timeout(25s). - Memory.
GOMEMLIMITis not set automatically. Set it from the container's memory limit through the downward API (the chart'sgoMemLimitFromLimitdoes this).