Skip to content

The v1 docs follow nauthera-server's main branch and its 0.x releases on the way to 1.0, so fields and APIs can still change.

v1

Architecture of nauthera-server

Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated

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:

ListenerDefaultServesExposure
Public HTTP:8080OAuth 2.0, OpenID Connect, the hosted sign-in UI and its /v1/auth/* API, the admin dashboard at /admin/ and the /v1/admin/* APIPublic, behind an ingress or load balancer
Admin HTTP:9090/metrics, /healthz (liveness), /readyz (readiness), /startupz (startup), /debug/pprof/* only when server.admin.pprof is setInternal only, never expose it
gRPC:9091grpc.health.v1 and nauthera.admin.v1.AdminServiceInternal. 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-email, /accept-invitation and 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-console client with DPoP-bound tokens held in memory, and reads and writes only through the /v1/admin/* API. See Admin Dashboard.

Storage

PostgreSQL is the only database, reached through the pure-Go jackc/pgx driver (database.driver: postgres, the default). Connect with a dsn, or with discrete host, port, user, password, name and sslmode components, which match the Secrets that CloudNativePG and Zalando publish. Set database.pgbouncer: true behind PgBouncer transaction pooling. Time columns are timestamptz, entity ids are UUIDs, and the audit log is partitioned by month.

The migrations are embedded in the binary. The runner applies each migration in its own transaction, verifies checksums, and serializes concurrent replicas with a PostgreSQL advisory lock. Turn it on with database.migrate: true, or run nauthera-server maintain as a separate step; see Backups & Upgrades.

Cache

A Redis-compatible cache (cache.*) holds the short-lived state that must be shared between replicas: single-use authorization codes, pushed authorization requests, device codes, DPoP replay records, spent captcha challenges 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.

Refresh tokens, the records of spent ones that detect a reuse, and every grant, session and subject revocation are kept in the database, so they survive losing the cache. Rate limiting uses memory or Redis, selected by rate_limit.backend; while Redis cannot answer, the Redis limiter falls back to a per-replica bucket.

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.

Email

Password reset links, email verification links, invitations, one-time sign-in codes, new-sign-in alerts, and notices of a role granted or revoked, an account status change, or a password, second-factor or recovery-code change are sent through SMTP; the Email page lists each message. 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, external identity providers, LDAP directories, identity-broker memberships, email domain claims and organizations. The Kubernetes operator drives it; nauthera-cli uses it for local development.

RPCsStatus
GetServerInfoImplemented
UpsertOIDCClient, GetOIDCClient, DeleteOIDCClient, ListOIDCClientsImplemented
UpsertBranding, GetBranding, DeleteBranding, ListBrandingsImplemented
UpsertIdentityProvider, GetIdentityProvider, DeleteIdentityProvider, ListIdentityProvidersImplemented
UpsertLDAPDirectory, GetLDAPDirectory, DeleteLDAPDirectory, ListLDAPDirectoriesImplemented
CreateOrganization, GetOrganization, ListOrganizations, UpdateOrganization, DeleteOrganizationImplemented
UpsertBrokerInvitation, DeleteBrokerInvitation, UpsertBrokerAcceptance, DeleteBrokerAcceptanceImplemented
UpsertEmailDomain, DeleteEmailDomain, ListEmailDomainsImplemented
ReportResourceStatusImplemented
UpsertAuthPolicy, DeleteAuthPolicyReturn Unimplemented
  • Upserts are idempotent full replaces and carry an etag for 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. An identity provider's client secret travels beside the spec, write-only, and is stored sealed under the key-encryption key.
  • Ownership. Each resource belongs to the verified identity that wrote it (its manager), which is part of its key beside the namespace and name. No request names the manager; the server takes it from the caller's certificate. Reads, lists and deletes see only the caller's own resources. The organization a client, branding or identity provider belongs to is decided on the server, from its issuer.
  • Bounds. Each identity's server.grpc.tls.allowed_clients entry names the organizations (by slug) and the namespaces it may act in, * for every one. A write outside them is refused (organization_not_granted, namespace_not_granted); a read, delete or listing outside them is answered as if the resource did not exist. The control plane never changes the main organization.
  • Audit. Every change, and every refusal, is recorded in the audit log with the caller's identity, address and request id, for example client.applied and branding.deleted.
  • Transport. TLS 1.3 with a verified client certificate. server.grpc.tls.allowed_clients lists each admitted identity, matched by SAN type (dns:<name> or uri:<uri>, never the Common Name), and the operations it may call (organizations, clients, brandings, identity_providers, ldap_directories, broker_memberships, email_domains). The server certificate and the client CA are read again at each handshake, so a renewal needs no restart. Plaintext needs an explicit server.grpc.allow_insecure: true and a loopback server.grpc.address, for local stacks only.

The operator side is described in Operator.

Lifecycle

  • Probes. /healthz, /readyz and /startupz on the admin listener, as the chart wires them. grpc.health.v1 shares the AdminService listener on :9091, which takes mutual TLS or, without TLS, listens on loopback only, so the kubelet probes the HTTP endpoints instead. The binary also has a healthcheck subcommand for images with no shell.
  • Graceful shutdown. On SIGTERM the server marks itself not ready, waits server.drain_delay (5s) so endpoints stop routing to it, then drains in-flight requests for up to server.shutdown_timeout (25s).
  • Memory. GOMEMLIMIT is not set automatically. Set it from the container's memory limit through the downward API (the chart's goMemLimitFromLimit does this).