# Architecture of nauthera-server

> How nauthera-server is put together: three listeners, a contract-first HTTP API, the embedded UI, PostgreSQL, Redis, an email outbox and a gRPC control plane.

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 |

```mermaid
flowchart LR
    users["Users & relying parties"] -->|":8080"| public
    kubelet["Prometheus & kubelet"] -->|":9090"| admin
    operator["Operator"] -->|":9091 · mTLS"| grpc
    cli["nauthera-cli (local dev)"] -.->|":9091 · dev only"| grpc

    subgraph server["nauthera-server"]
        direction TB
        public["Public HTTP"]
        admin["Admin HTTP"]
        grpc["gRPC"]
    end

    public --> db[("PostgreSQL")]
    grpc --> db
    public --> cache[("Redis")]
    public -.->|"outbox"| smtp[("SMTP relay")]
```

## 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](https://www.nauthera.io/docs/v1/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](https://www.nauthera.io/docs/v1/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](https://www.nauthera.io/docs/v1/login-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](https://www.nauthera.io/docs/v1/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](https://www.nauthera.io/docs/v1/operations#schema-migrations).

## 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](https://www.nauthera.io/docs/v1/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.

| RPCs | Status |
|---|---|
| `GetServerInfo` | Implemented |
| `UpsertOIDCClient`, `GetOIDCClient`, `DeleteOIDCClient`, `ListOIDCClients` | Implemented |
| `UpsertBranding`, `GetBranding`, `DeleteBranding`, `ListBrandings` | Implemented |
| `UpsertIdentityProvider`, `GetIdentityProvider`, `DeleteIdentityProvider`, `ListIdentityProviders` | Implemented |
| `UpsertLDAPDirectory`, `GetLDAPDirectory`, `DeleteLDAPDirectory`, `ListLDAPDirectories` | Implemented |
| `CreateOrganization`, `GetOrganization`, `ListOrganizations`, `UpdateOrganization`, `DeleteOrganization` | Implemented |
| `UpsertBrokerInvitation`, `DeleteBrokerInvitation`, `UpsertBrokerAcceptance`, `DeleteBrokerAcceptance` | Implemented |
| `UpsertEmailDomain`, `DeleteEmailDomain`, `ListEmailDomains` | Implemented |
| `ReportResourceStatus` | Implemented |
| `UpsertAuthPolicy`, `DeleteAuthPolicy` | Return `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](https://www.nauthera.io/docs/v1/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).
