Authnubis is an AI built authz/authn provider to create a centralized user system for apps.
  • TypeScript 92.3%
  • JavaScript 3.7%
  • PLpgSQL 2.4%
  • CSS 1.2%
  • Shell 0.2%
  • Other 0.2%
Find a file
Dunemask 83fd9a0b35
All checks were successful
Build Image / build-image (push) Successful in 2m1s
[CHORE] add README, drop demo/e2e/legacy migrations from the public repo
README.md (new)
- There was none. Hero and footer use the existing branding renders; the release and
  build-status badges are Forgejo-served and both resolve, not decorative shields.
- Two mermaid diagrams drawn from the code rather than from a template: the
  authorization-code flow through this provider's own /interaction/:uid, and the DPoP
  proof lifecycle as the ordered checks dpop.service.ts actually performs, with distinct
  terminals for replay (401), store-unreachable (503, fail-closed) and accept.
- docs/media/demo.gif is a real recording of this build driven headless through
  Playwright against a fresh database: first run, creating the first operator, then the
  signed-in console. Not a mock-up.
- Documents the two .env traps that cost real time: a missing variable gives an opaque
  ZodError, and SMTP_USER=/SMTP_PASS= as EMPTY strings FAIL validation because the schema
  is .min(1).optional() -- they must be absent, not empty, so .env.example copied
  verbatim does not boot.

Removed from the public repo
- demo/ (24 files), e2e/ (38 files), .npmrc.example, docs/demo.md, docs/e2e.md.
- migrations/ (22 hand-written SQL files, 0001-0023, no 0018). Nothing applied them any
  more: the Prisma baseline holds the full final state. History stays in git.

The cascade, which is most of the work
- package.json: demo/* and e2e dropped from workspaces; the e2e tsconfig program removed
  from `tsc`; the e2e, e2e:replicas and test:rotation scripts deleted.
- bun.lock regenerated. Without this `bun install --frozen-lockfile` in the build image
  fails on workspace entries that no longer exist -- CI would have broken on the next
  push, not at merge time.
- tsconfig.json, Dockerfile, .dockerignore, deploy/k3d/authnubis.yaml: stale paths out.
- Roughly 34 citations across 24 files pointed at docs/e2e.md scenario labels ("F8",
  "F17", ...) and another dozen at migrations/00NN_*.sql. A citation exists to tell a
  reader where a rule is pinned down, so each was rewritten to name the CONSTRAINT or the
  behaviour rather than the dead file -- e.g. the project_membership_status_known CHECK
  instead of migrations/0013. None now points at a file that does not exist.
- Deliberately kept: /app/migrations and MIGRATIONS_DIR in docs, which describe what the
  server IMAGE no longer carries and are still true, and narrative "e2e step N" mentions,
  which read as history rather than as paths.

Gates: tsc exit 0; 537 pass / 112 skip / 0 fail, unchanged, so nothing under test
depended on the deleted trees. `bun install --frozen-lockfile --dry-run` passes. Verified
no non-comment line changed in any of the citation edits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-31 10:40:13 -06:00
.forgejo/workflows [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
apps [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
deploy [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
docs [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
packages [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
prisma [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
scripts [FEATURE] rebrand cairo-v2 to authnubis and move the data layer onto Prisma 2026-10-31 09:02:49 -06:00
.dockerignore [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
.env.example [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
.gitignore [FEATURE] rebrand cairo-v2 to authnubis and move the data layer onto Prisma 2026-10-31 09:02:49 -06:00
biome.json [FEATURE] rebrand cairo-v2 to authnubis and move the data layer onto Prisma 2026-10-31 09:02:49 -06:00
bun.lock [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
compose.yaml [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
Dockerfile [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
package.json [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
prisma.config.ts [FEATURE] rebrand cairo-v2 to authnubis and move the data layer onto Prisma 2026-10-31 09:02:49 -06:00
README.md [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00
tsconfig.json [CHORE] add README, drop demo/e2e/legacy migrations from the public repo 2026-10-31 10:40:13 -06:00

authnubis

A self-hosted OpenID Connect provider where the hard guarantees are enforced by the database, not by the process.

release build license

Bun NestJS Postgres Prisma TypeScript


Authnubis issues OIDC/OAuth 2.0 tokens for your own services. Authorization code + PKCE for every client, DPoP sender-constrained access tokens, per-tenant signing keys sealed at rest, a long-poll revocation feed, and a typed SDK so a consuming service writes no auth code — just a schema file and route wiring.

It is built to run as N replicas, and that constraint drove the design: every single-use token, every expiry window, and every replay cache is adjudicated by Postgres, because N replicas have N clocks and N in-process Maps.

Psychostasia: Anubis sets the heart against Maat's feather and the scale settles. The mark is that equilibrium glyph. Flavour only — the rest of this document is mechanism.

authnubis demo


What is actually unusual here

DPoP (RFC 9449), with the replay cache nobody writes. Access tokens carry cnf.jkt; every request carries a proof pinned by htm, htu, and ath. The jti is accepted exactly once globally — the cache lives on a shared cross-replica store (SharedStore.claimFirstUse, namespace dpop-oidc, TTL 120s = 2x the 60s skew allowance), not in a per-process Map that silently multiplies the replay allowance by your replica count. It fails closed: store unreachable is a 503, never an allow. See docs/oidc.md "DPoP (RFC 9449)".

The database clock is the correctness boundary. Expiry and single-use are SQL, deliberately: expires_at > now(), now() + make_interval(...). A single-use token is claimed with one statement —

UPDATE ... SET consumed_at = now() WHERE id = $1 AND consumed_at IS NULL RETURNING ...

— because findFirst-then-update is how a single-use token becomes redeemable twice. Prisma owns the schema and typed delegates are the default, but every site that drops to raw SQL does so to keep the clock or a SQL expression as the authority, and carries a comment naming the guarantee it encodes. See docs/data-layer.md "conversion rules".

Signing keys are sealed at rest, so a Postgres dump alone cannot mint tokens. project_signing_key.private_jwk is AES-256-GCM with a fresh 12-byte nonce per seal, AAD-bound to the kid, under an env keyring (AUTHNUBIS_SIGNING_KEY_SECRET, ..._V<n>; highest configured seals). A schema CHECK enforces that exactly one of the plaintext column or the three sealed columns is populated, so "is this row sealed" is a schema fact rather than a jsonb-sniffing heuristic. The key is unsealed per signature and never cached — measured at ~3.4 µs against ~131 µs already spent, i.e. ~2.6% of per-signature cost, in exchange for having no second source of truth about which key is current. See docs/oidc.md "sealing the private half".

Realms are resolved by kid, not by aud. A realm is a project, or the internal realm that signs authnubis's own admin surface. Choosing a keystore from the claimed aud means choosing keys on the strength of an unauthenticated string; the kid is in the JOSE header and each project_signing_key belongs to exactly one realm, so the signature already proves which project minted the token. Resolution is kid -> realm, and aud is then checked against that realm's audience (still required by RFC 9068 §4 — just no longer the thing selecting the keys). The payoff: myapp-dev and myapp-prod can both declare aud: "myapp" and are told apart cryptographically. See docs/identity.md "two realms".

Key rotation publishes before it signs. A new key enters the JWKS immediately and signs nothing; only after MIN_PROPAGATION_SECONDS (300, deliberately greater than the JWKS cache max-age of 120) may it be activated. The outgoing key keeps verifying for one max access-token lifetime, then is pruned. kid is unique and never reused.


The authorization code flow, as this provider runs it

sequenceDiagram
    autonumber
    actor U as Browser
    participant BFF as Your BFF<br/>(@authnubis/server)
    participant AU as authnubis /auth<br/>(oidc-provider)
    participant IX as /interaction/:uid<br/>(authnubis's own UI)
    participant TK as authnubis /token
    participant RS as Your API

    U->>BFF: GET /login
    BFF->>U: 302 -> /auth?response_type=code&code_challenge=S256(v)&dpop_jkt
    U->>AU: GET /auth
    AU->>U: 302 -> /interaction/:uid<br/>(uid IS the capability: unguessable, single-use, 600s)
    U->>IX: GET /interaction/:uid (Accept: text/html)
    Note over IX: password -> second factor -> session.<br/>Ordering lives in signin.service.ts and nowhere else.
    IX->>AU: login result + first-party Grant auto-minted<br/>for the requested scopes only (no consent UI, by design)
    AU->>U: 302 -> redirect_uri?code=...
    U->>BFF: GET /callback?code=...
    BFF->>TK: POST /token + PKCE verifier<br/>+ private_key_jwt (ES256) + DPoP proof
    Note over TK: oidc-provider's access_token is REPLACED by an<br/>authnubis access token: aud=project.audience,<br/>roles+perms resolved at mint, cnf.jkt copied,<br/>sid = one authnubis session per grant
    TK-->>BFF: { access_token (ES256, typ=at+jwt), id_token, refresh_token,<br/>authnubis_session_id }
    BFF->>RS: request + Authorization: DPoP <at> + DPoP: <proof>
    RS->>AU: GET /jwks/{project} (cached 120s)
    Note over RS: verify sig -> kid resolves the realm -><br/>check aud -> check cnf.jkt == proof thumbprint
    RS-->>U: 200

The authorization endpoint is /auth, not /authorize. /interaction is deliberately not mounted by oidc-provider — the login UI is this repo's, server-rendered, and content-negotiated (HTML for browsers, documented JSON for API clients). There is no consent screen and none is planned; a first-party client gets an auto-granted Grant for exactly the scopes requested, and an is_first_party=false client cannot complete an authorization request. Both deviations that follow from that are written down in docs/oidc.md "two deliberate deviations".


DPoP proof lifecycle

flowchart TD
    A["Client holds a non-extractable<br/>P-256 key (CryptoKey, unexportable)"] --> B["Sign proof JWT<br/>typ=dpop+jwt, jwk=public half<br/>htm, htu, iat, jti, ath"]
    B --> C{"typ == dpop+jwt and<br/>alg in ES/RS/PS/EdDSA?"}
    C -- "no / none / HMAC" --> X["401 dpop_invalid<br/>(an HMAC proof is forgeable<br/>by the verifier: it proves nothing)"]
    C -- yes --> D{"embedded jwk free of<br/>d k p q dp dq qi oth?"}
    D -- no --> X
    D -- yes --> E{"signature verifies<br/>against that embedded key?"}
    E -- no --> X
    E -- yes --> F{"htm == method and<br/>htu == AUTHNUBIS_PUBLIC_URL + path<br/>(query/fragment stripped)"}
    F -- no --> X
    F -- yes --> G{"abs(now - iat) <= 60s?"}
    G -- no --> X
    G -- yes --> H["claimFirstUse(ns=dpop-oidc,<br/>key=sha256(jti), ttl=120s)<br/>SHARED store, one round trip"]
    H -- "already claimed" --> Y["401 dpop_replay<br/>unseen means unseen by ANY replica"]
    H -- "store unreachable" --> Z["503 FAIL CLOSED<br/>never an allow"]
    H -- "first use" --> I{"ath == base64url(sha256(access_token))?"}
    I -- no --> X
    I -- yes --> J{"RFC 7638 thumbprint<br/>== token cnf.jkt?"}
    J -- no --> K["401 dpop_key_mismatch"]
    J -- yes --> L["accepted"]

    style X fill:#7f1d1d,stroke:#ef4444,color:#fff
    style Y fill:#7f1d1d,stroke:#ef4444,color:#fff
    style Z fill:#78350f,stroke:#f59e0b,color:#fff
    style L fill:#064e3b,stroke:#10b981,color:#fff

Order matters twice over: the replay claim is checked after signature/htm/htu/iat, so garbage costs no store round trip. The TTL is 2 x skew on purpose — a proof with iat at +59s is still valid at now+60, so a 60s TTL would evict the jti while the proof still verifies.

htu is validated against AUTHNUBIS_PUBLIC_URL, never the observed Host: behind a proxy the observed authority is the proxy's and every proof would fail. Refresh tokens are DPoP-bound too (refresh_token.jkt); presenting one with a different key kills the whole family.

DPoP stops replay, not revocation. Withdrawal is the revocation feed's job — an RFC 8936-shaped long poll serving RFC 8417 Security Event Tokens with CAEP event names at GET /events/revocations. Complementary mechanisms, not substitutes.


Quickstart

Requires Bun and Docker. Everything in compose.yaml binds to 127.0.0.1 only — an identity provider answering on 0.0.0.0 on a laptop is a different product.

# 1. Dependencies (postinstall runs `prisma generate`; needs no database)
bun install

# 2. Postgres on 127.0.0.1:5433 + a mail sink on 127.0.0.1:1025 (UI at :8025)
docker compose up -d postgres mailpit

# 3. Configure. READ THE NEXT SECTION BEFORE YOU START THE SERVER.
cp .env.example .env

# 4. Apply the schema (forward-only, advisory-locked, never prompts or resets)
bun run migrate

# 5. Build the admin SPA. `dev`/`start` only SERVE apps/admin-ui/dist, they never build it,
#    and without it every brand asset and the whole console 404s.
bun run --cwd apps/admin-ui build

# 6. Run it
bun run dev        # watch mode
# bun run start    # once

Operator console: http://localhost:3000/. Discovery: http://localhost:3000/.well-known/openid-configuration. Outbound mail lands in Mailpit at http://127.0.0.1:8025.

To claim the owner account on first run, set AUTHNUBIS_SETUP_TOKEN (16+ chars; unset means bootstrap is closed) and POST /api/bootstrap { setupToken, email, password }.

The two .env gotchas that will cost you an hour

1. .env needs far more than DATABASE_URL. Config is validated with zod at boot — by design, so a typo is a fatal startup error instead of a 500 on someone's first signup. The failure mode is an opaque ZodError that does not always make the missing key obvious. Start from .env.example, which is the authoritative list and documents every field: database (DATABASE_URL, PG_POOL_MAX), server (PORT, HOST, LOG_LEVEL), issuer (AUTHNUBIS_PUBLIC_URL, AUTHNUBIS_INTERNAL_AUDIENCE, AUTHNUBIS_INTERNAL_ACCESS_TTL_SECONDS, AUTHNUBIS_OIDC_JWK), WebAuthn (AUTHNUBIS_RP_ID, AUTHNUBIS_RP_NAME, AUTHNUBIS_RP_ORIGINS), registration (INVITE_TTL_SECONDS, CONFIRMATION_TTL_SECONDS, AUTHNUBIS_DEFAULT_SIGNUP_ROLE) and mail.

2. SMTP_USER= and SMTP_PASS= as EMPTY strings fail validation — they must be ABSENT. Both are z.string().min(1).optional() (apps/server/src/modules/mail/mail.config.ts:8-9), and dotenv turns a bare SMTP_USER= into "", which is not undefined and not min(1). Delete or comment out both lines in your .env unless you are actually setting credentials — and if you set one, set both; a cross-field refine requires the pair.

Two more worth knowing up front:

  • Leave AUTHNUBIS_ISSUER unset. The issuer is AUTHNUBIS_PUBLIC_URL. AUTHNUBIS_ISSUER survives only as an explicit override and must agree; a mismatch is a fatal boot error naming both values. Two variables for one fact is how you end up signing tokens with an iss nothing accepts.
  • AUTHNUBIS_OIDC_JWK unset generates an ephemeral key and logs loudly: every id_token becomes unverifiable on restart. Never silently persistent, never silently ephemeral.

Useful scripts

command what
bun run dev / bun run start the server, watch / once
bun run migrate prisma migrate deploy
bun run db:status / db:validate migration status / schema validity (no DB needed for the latter)
bun run db:diff the drift gate. 0 = clean, 2 = drift. Needs SHADOW_DATABASE_URL (dev/CI only — Prisma resets that database)
bun run db:diff:script emit the drift as SQL instead of a summary
bun test apps packages the unit + module suite
bun run lint / bun run format Biome check / write
bun run build:sdk / bun run pack:sdk build the four SDK packages / pack them to dist/sdk/
bun run signing-key:rewrap sweep plaintext signing keys onto the current secret (--census to report only)
bun run totp:rewrap same, for sealed TOTP secrets

Do not run prisma migrate dev: pfx_id(), 20 CHECK constraints, 3 partial/expression unique indexes and 2 triggers live in raw SQL that schema.prisma deliberately does not model, and migrate dev will try to drop all of it. Author migrations with db:diff:script and hand-review. The reasoning — including why modelling the partial uniques would hand the Prisma client a false uniqueness on role.slug and project_signing_key.project_id — is in docs/data-layer.md.


Consuming it: the SDK

Four packages, all at 0.1.0, published to the Forgejo npm registry.

package for
@authnubis/core isomorphic contracts: claims, the canonical auth_event vocabulary, SET/CAEP schemas, policy IR, errors, prefixed IDs. Depends on zod only.
@authnubis/server consuming backends: token + DPoP verification, JWKS cache, BFF, sessions, the revocation feed client, the policy evaluator, Nest/middleware adapters.
@authnubis/client browsers: a non-extractable DPoP key, a per-request proof, login/logout, a permissions hook. Imports only @authnubis/core + jose; no node:* (test-enforced).
@authnubis/codegen the .authnubis schema DSL compiler — a schema file becomes a typed can(), a SQL predicate builder, and a manifest. Ships the nubis CLI.

.npmrc in your consuming project:

@authnubis:registry=https://forgejo.dunemask.dev/api/packages/elysium/npm/
//forgejo.dunemask.dev/api/packages/elysium/npm/:_authToken=${FORGEJO_NPM_TOKEN}

Use a read:package token, via an env var — never a literal in the file.

bun add @authnubis/core @authnubis/server   # add @authnubis/client @authnubis/codegen as needed

Always name @authnubis/core explicitly. Siblings are declared as optional peer dependencies (peerDependenciesMeta.optional) so that your own resolution of core wins and there is exactly one core instance in the graph — which is what makes instanceof AuthnubisError and zod schema identity work. Optional peers are not auto-installed. A missing core surfaces as ERR_MODULE_NOT_FOUND: @authnubis/core at import.

No registry access? bun run pack:sdk produces dist/sdk/authnubis-{core,server,client,codegen}-0.1.0.tgz and installing those by path is the identical artifact.

Depth: docs/sdk.md (facade, BFF, sessions, revocation, framework adapters) and docs/dsl.md (the schema DSL and its drift check).


Container images

Two images, from one Dockerfile, built in one CI job from one commit:

image base role
forgejo.dunemask.dev/elysium/authnubis gcr.io/distroless/base-debian12:nonroot the server — a bun build --compile single-file executable plus the admin SPA. No shell, no node, uid 65532.
forgejo.dunemask.dev/elysium/authnubis-migrate node:22-bookworm-slim migrations only. ENTRYPOINT ["./node_modules/.bin/prisma","migrate","deploy"]. Carries prisma/ and prisma.config.ts and nothing else — no server, no SPA.

Tags: v0.1.0, latest (on main only), and the commit SHA (always).

Migrations run as an init container from the second image, and the pair must be pinned together from the same build. The split is not stylistic: prisma migrate deploy is a Node CLI that loads prisma.config.ts through its own loader and spawns its own schema engine, so it cannot be compiled into the distroless server image the way the old bespoke runner was. migrate deploy applies only what is pending and takes Postgres advisory locks itself, so N replicas starting at once is safe.

CI emits both digests from a single step and hard-fails if either is empty — it structurally cannot publish one without the other. Pinning them into your deploy overlay is still manual, and a migrate image newer than the server applies a schema the server does not know about. Use the two digests from the same run. Manifests and the full runbook: docs/deploy.md; CI contract: docs/ci.md.


Testing and CI

bun test apps packages

537 pass, 112 skip at v0.1.0. The skips are the DB-backed specs (*.db.spec.ts), which self-skip without TEST_DATABASE_URL; the pipeline gate runs with no Postgres service on purpose, and real database coverage is a separate harness. Run *.db.spec.ts files one at a time — registerSharedStore is a module-level global and several in one bun test process cross-contaminate.

CI is Forgejo Actions in .forgejo/workflows/: build-image.yml (gate, both images, Forgejo Release) and publish-sdk.yml (npm, on v* semver tags only). The gate — typecheck then bun test apps packages — runs before anything touches a registry. A v* tag fires both workflows in parallel from one push; neither observes the other, and npm publish is not transactional, so a partial publish is re-converged by re-running the job (already-published versions are skipped). Both caveats and the full failure table are in docs/ci.md.


Documentation

Written for agents and new maintainers orienting from cold; read the doc before the source.

doc topic
docs/file-map.md start here — where everything lives and which doc explains it
docs/identity.md users, the two realms, the sign-in ordering, password/TOTP/recovery/passkeys, secret storage, sessions + refresh rotation
docs/oidc.md oidc-provider wiring, the two keystores, access-token claims, key rotation, sealing at rest, DPoP, the revocation feed, RP-initiated logout
docs/data-layer.md Prisma ownership, the conversion rules, where raw SQL is load-bearing, what the generated types get wrong
docs/enrollment.md service clients, default-deny capabilities, mandatory client binding, manifests
docs/registration.md invites, open signup, email confirmation, enrollment tickets
docs/projects.md project CRUD, token policy, two-factor policy, OIDC client registration
docs/dsl.md the .authnubis permission DSL and its drift check
docs/sdk.md the four packages, in depth
docs/threat-model.md the findings, what is closed, and what is explicitly accepted
docs/deploy.md · docs/backup.md · docs/ci.md running it
docs/events.md · docs/admin-ui.md · docs/branding.md audit surface, operator console, brand assets

Status

v0.1.0. First tagged release. Treat the published surface as early: the internal permission vocabulary and the SDK's exported shapes are deliberately narrow because adding to them is a bigger decision than adding a route. Known gaps and deliberately-accepted trade-offs are not hidden — they are written down in docs/threat-model.md and in the "UNSETTLED" sections of the individual docs.

License

TBD. No LICENSE file is present in this repository yet, and no license is declared in package.json. Until one is added, assume all rights reserved.