- TypeScript 92.3%
- JavaScript 3.7%
- PLpgSQL 2.4%
- CSS 1.2%
- Shell 0.2%
- Other 0.2%
|
All checks were successful
Build Image / build-image (push) Successful in 2m1s
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>
|
||
|---|---|---|
| .forgejo/workflows | ||
| apps | ||
| deploy | ||
| docs | ||
| packages | ||
| prisma | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| biome.json | ||
| bun.lock | ||
| compose.yaml | ||
| Dockerfile | ||
| package.json | ||
| prisma.config.ts | ||
| README.md | ||
| tsconfig.json | ||
A self-hosted OpenID Connect provider where the hard guarantees are enforced by the database, not by the process.
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.
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_ISSUERunset. The issuer isAUTHNUBIS_PUBLIC_URL.AUTHNUBIS_ISSUERsurvives 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 anissnothing accepts. AUTHNUBIS_OIDC_JWKunset generates an ephemeral key and logs loudly: everyid_tokenbecomes 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.
