feat(auth): keep issuer state in Postgres

The issuer kept everything behind one get/set/remove/scan interface, which
is what a library that must run on any provider's cache can offer. Three of
the things kept there could not actually be served by it.

An authorization code must be redeemable once and a refresh token spendable
once, and through get and set the check and the write are separate steps —
so two requests arriving together both read an unspent record, and both mint
a session. In the refresh case that also means the reuse which reveals a
stolen token is never recorded, because recording it is the write that the
second caller overwrites. Each now has a table and an interface of its own:
redeeming is one `delete ... returning`, spending is one
`update ... where time_used is null returning *`, so exactly one caller is
ever told it went first. This is the same argument the device grant already
made, applied to the two records that had it too.

Signing keys move for a different reason. Nothing races for them; they are
the one record whose loss ends every session at once, and a cache is a place
things may be evicted from. They are retired by setting a column rather than
deleted, so the tokens they signed stay verifiable until they expire.

Both credential tables store a hash and never the credential, as the device
grant does. An authorization code travels in a query string and so passes
through history, referrer headers and any log along the redirect; a refresh
token resumes a session outright.

What is left in the generic store is the rate-limit counters — written far
more often than read, meaningless within the hour, and allowed to be
approximate, since a lost increment costs one guess out of ten. Those move
to Postgres too, so the only key-value binding this deploys with is gone and
the control plane's state is one database. That was the point: nothing here
now depends on a primitive a self-hoster cannot run.

The generic scan also gained the separator on its prefix, so scanning `a`
cannot return what is under `ab` — subjects and email addresses are both
prefixes of longer subjects and email addresses.

Deploying this signs everyone out. The signing keys and refresh tokens are
in a store that is being left behind, so the issuer starts with a fresh key
set and every existing token stops verifying.
This commit is contained in:
Wanjohi
2026-09-05 13:56:39 +03:00
parent 349305d0cc
commit f25c9af545
26 changed files with 4241 additions and 185 deletions

View File

@@ -5,28 +5,38 @@ The authentication worker for Nestri — a Cloudflare Worker built on
## What it does
Hosts the OpenID Connect / OAuth issuer and the login UI:
Hosts the OAuth issuer and the sign-in UI:
- **Steam OAuth** — the primary login flow. After Steam redirects back, the worker fetches the
player's profile, creates (or finds) the `User` + `LinkedAccount` rows in Postgres, auto-creates a
personal team on first login, and issues a JWT `user` subject containing `{ userID, linkedAccountID }`.
- **SSH login** — authenticates a device via its SSH fingerprint (keyed by `SSH_AUTH_KEY`),
resolving the identity through `Steam.resolveSshIdentity` in `@nestri/core`.
- **Email code** — the only provider, on purpose. Verifying an email address is the one thing that
brings an account into existence, so an account is exactly as recoverable as its email. The
`success` callback finds or creates the `User` row, ensures a personal team exists, and issues a
JWT `user` subject containing `{ userID, linkedAccountID }`.
- **Device authorization grant** (RFC 8628) — for programs with no browser. A client starts a grant,
a person approves it in a browser, and the client collects tokens by polling. Connecting a Steam
account is not a sign-in and lives in `apps/api` instead, against a user who already exists.
## Key details
- Signing keys are generated at runtime and persisted in the `AuthStorage` KV namespace.
- **All issuer state is in Postgres.** There is no key-value binding. Signing keys, authorization
codes, refresh tokens and device grants each have a table, because each is either a record whose
loss ends every session (the keys) or one with a transition that must happen exactly once while
two callers are touching it — a code is redeemed once, a refresh token is spent once, a grant is
approved once. A store that reads and writes whole records cannot promise that. What is left in
the generic `auth_kv` table is the rate-limit counters, which are allowed to be approximate.
- Authorization codes, refresh tokens and device codes are stored as hashes. Each is a bearer
credential, so what is kept is enough to recognise one and not enough to present it.
- JWT subjects are defined in `@nestri/core/auth/subjects`.
- The API worker calls this worker via a service binding (`AUTH`), verified through `AUTH_ISSUER_URL`.
- The API worker verifies tokens against this issuer through `AUTH_ISSUER_URL`.
## Structure
```text
src/index.ts # Worker entrypoint: issuer config + success callbacks (steam, ssh)
src/index.ts # Worker entrypoint: issuer config, stores, success callback
src/email.ts # Verification code delivery
test/ # Worker tests
```
## Running
Deployed through Alchemy (`apps/auth` worker in `alchemy.run.ts` at the repo root) with bindings
`AuthStorage` (KV), `HYPERDRIVE` (Postgres), `STEAM_API_KEY`, `SSH_AUTH_KEY`.
Deployed through Alchemy (`apps/auth` worker in `alchemy.run.ts` at the repo root). Its only
stateful binding is `HYPERDRIVE` (Postgres), alongside the mail settings.

View File

@@ -1,10 +1,13 @@
import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types';
import type { Hyperdrive } from '@cloudflare/workers-types';
import { issuer } from '@nestri/auth/index';
import { CodeProvider } from '@nestri/auth/provider/code';
import { CloudflareStorage } from '@nestri/auth/storage/cloudflare';
import { CodeUI } from '@nestri/auth/ui/code';
import { Actor } from '@nestri/core/actor';
import { PostgresCodeStore } from '@nestri/core/auth/authorization-code';
import { PostgresDeviceStore } from '@nestri/core/auth/device-grant';
import { PostgresRefreshStore } from '@nestri/core/auth/refresh-token';
import { PostgresKeyStore } from '@nestri/core/auth/signing-key';
import { PostgresStorage } from '@nestri/core/auth/storage';
import { subjects } from '@nestri/core/auth/subjects';
import { Env } from '@nestri/core/env';
import { Team } from '@nestri/core/team/index';
@@ -14,7 +17,6 @@ import { LinkedAccount } from '@nestri/core/user/linked-account';
import { sendVerificationCode } from './email.js';
type Env = {
AuthStorage: KVNamespace;
HYPERDRIVE: Hyperdrive;
EMAIL_SEND_URL?: string;
EMAIL_API_KEY?: string;
@@ -61,15 +63,21 @@ export default {
Env.init(env as unknown as Record<string, unknown>);
const inner = issuer({
subjects,
storage: CloudflareStorage({
namespace: env.AuthStorage
}),
// Not the KV store the rest of this uses, and the difference
// matters. A device grant is answered by a browser and collected by
// a program polling at the same time, so approving it and redeeming
// it each have to be one operation that either happens or does not.
// A store that reads and writes whole records lets those two undo
// each other; a conditional update does not.
// One database behind all of it, and nothing that only exists on
// one hosting provider. What is left in the generic store is the
// rate-limit counters — the only records here that are allowed to
// be approximate, and the only ones whose shape is not worth a
// migration.
storage: PostgresStorage(),
// The rest each got an interface of their own because each has a
// transition that must happen exactly once while two parties are
// touching the same record: a code is redeemed once, a refresh
// token is spent once, a grant is approved once. A store that reads
// and writes whole records cannot promise that — the second caller
// overwrites what the first decided. A conditional update can.
keyStore: PostgresKeyStore(),
codeStore: PostgresCodeStore(),
refreshStore: PostgresRefreshStore(),
deviceStore: PostgresDeviceStore(),
allowDeviceClient: async (clientID) => DEVICE_CLIENTS.has(clientID),
// One provider, on purpose.