Version 0.1.0-alpha.0
An OpenID Connect provider for FairGarden services, built on oidc-provider. People sign in with a passkey, or with a code or link sent to their email, and decide service by service what each one can see.
It keeps only what is sensitive or has to be verified: email address, name, phone number, mailing and residential addresses. Everything else about a person belongs to the services; the members service is where a profile is filled in, and it can hand its own claims to other services through this one (see Claims from other services).
pnpm dev # http://localhost:3010Run apps/members alongside (port 3020), or pnpm dev at the repository root
for everything, to sign in to a service.
Nothing else is needed locally. Without a database URL, an embedded Postgres
(PGlite) starts in .data/id and is migrated for you.
Without SMTP, email goes to the mock mailbox at
/dev/mailbox, and the sign-in link is
logged. .env.development enrols the members service; override anything in
.env.development.local.
The docs are a site in this repository, covering every piece in more depth than this page:
pnpm --filter @fairgarden/id-docs dev # http://localhost:3034pnpm test # unit tests (vitest)
pnpm exec playwright install chromium # once
pnpm test:e2e # browser tests (Playwright)
pnpm policy:test # the Rego policies (needs the opa CLI)Nothing outside this repository is needed. The browser tests build the
service for production and sign in to it from e2e/mock-service.ts, which is
both an ordinary OIDC client and a service supplying the club scope's
claims; passkeys use Chromium's virtual authenticator. .github/workflows/test.yml
runs all three.
Every variable is described in .env.example. The ones that matter most:
| Variable | |
|---|---|
FG_ID_URL |
The public URL. The OIDC issuer, passkey domain and email links follow from it, so this is what white-labelling changes. |
FG_ID_NAME |
The name people see on every page, email and passkey prompt. |
FG_ID_DATABASE_URL |
Any Postgres. Neon's Vercel integration sets DATABASE_URL, which is used when this is not. |
FG_ID_SMTP_URL |
Where sign-in emails are sent from. |
FG_ID_SERVICE_<NAME>_* |
A service that signs in here. |
Inside a monolith the issuer is FG_ID_URL plus the mount point, such as
https://example.com/id.
Each service is a group of variables; the name becomes its client ID.
FG_ID_SERVICE_EVENTS_URL=https://events.example.com
FG_ID_SERVICE_EVENTS_SECRET=a-long-random-secretIts redirect URI defaults to <URL>/auth/callback and its post-logout URI to
<URL>; _REDIRECT_URIS, _LOGOUT_URIS, _NAME and _METADATA (any other
client metadata, as JSON) change them. A service without a secret is a public
client. Every client must use PKCE.
The first time a service asks for something, the person sees each scope with
what it would share, and chooses. openid is always shared; email and
profile start ticked; phone, address, residential_address and anything
from another service start unticked, so sharing them is always a choice. What
they refuse is remembered, and the account page at /account lists every
service with what it can see and a way to remove its access, which revokes its
tokens at once.
| Scope | Claims |
|---|---|
openid |
sub, amr |
email |
email, email_verified |
profile |
name, updated_at |
phone |
phone_number, phone_number_verified |
address |
address (mailing) |
residential_address |
residential_address |
offline_access |
a refresh token |
A service can own scopes whose claims it keeps itself:
FG_ID_SERVICE_MEMBERS_CLAIMS=membership # scope:claim,claim — here, both "membership"When a person shares membership with another service, this one POSTs a
ClaimsReview to members at <URL>/api/v1alpha1/claimsreviews (or
_CLAIMS_ENDPOINT) and passes on what it answers, much as Kubernetes sends an
admission review. Nothing is copied here. The request carries a JWT signed
with the current token key, typed fg-claims-review+jwt and addressed to the
service, which checks it against this service's JWKS. The contract is in the
OpenAPI document's webhooks; apps/members implements it.
The pages call a Kubernetes-style API, one route file per resource under
app/api/:
/apiand/api/v1alpha1list the versions and resources/api/v1alpha1/<resource>[/<name>[/<subresource>]]serves them/api/openapi/v3is the OpenAPI 3.1 document
Objects have apiVersion: id.fairgarden.org/v1alpha1, kind, metadata,
spec and status; lists are <Kind>List; every error is a Status;
updates are JSON merge patches, refused with 409 when metadata.resourceVersion
is stale. v1alpha1 means it can still change. The routes and their Zod
schemas are in lib/server/api-*.ts and lib/api/schemas.ts, and the OpenAPI
document is generated from them.
The OIDC endpoints themselves are oidc-provider's, at /oidc/*, with
discovery at /.well-known/openid-configuration.
Drizzle, over pg. The schema is lib/server/schema.ts; migrations are in
drizzle/.
pnpm db:generate --name add-thing # write the next migration from the schema
pnpm db:migrate # apply what is pending
pnpm db:rollback [--steps N | --to TAG]
pnpm db:status
pnpm db:studiodrizzle-kit writes each migration forward; its rollback is the hand-written
<tag>.down.sql beside it, which db:generate leaves as a stub. Migrations
run in order, one transaction each, under a lock, and one edited after it ran
is refused. A real database is migrated by the build that deploys it —
pnpm migrate, which is fg-dist migrate and runs after
next build — or by hand with db:migrate, and never at start-up, so a
deployment never changes its schema by surprise. Every migration has to work
with the release before it too: a Vercel rollback runs no build.
Token signing keys and cookie secrets live in the database and rotate by
themselves every 30 days (FG_ID_KEY_ROTATION_DAYS). A new key is published
a day before it signs anything, and kept for two rotations after, so services
that cache the JWKS never see a token they cannot verify. Running instances
pick up a change within a minute.
pnpm keys list
pnpm keys rotate [--retire] # sign with a new key now, as after a leakTo keep keys in a secret store instead, set FG_ID_JWKS (pnpm keys generate prints one; pnpm keys rotate --env prints the next) and
FG_ID_COOKIE_SECRETS.
Authorization goes through one place, lib/server/policy.ts, built on
@fairgarden/policy: authz (may this person do this
to this resource — a Kubernetes SubjectAccessReview's attributes) and
release (which scopes may this service be offered, and why not the others).
The built-in rules answer until an organization says otherwise.
policies/id.rego is the same rules in Rego, with places for an
organization's own (deny, withheld). In a distribution, the organization
writes its rules in its policies/, the distribution builds them on id's
once, and id's build takes a copy (fg-dist policy use) to run in process —
no OPA server, nothing to publish. examples/privacy is an organization's
rules to start from.
pnpm policy:test # id's rules and tests
fg-policy test --base policies --dir examples/privacy # with an organization's on top/policy shows the policy in force to anyone: every decision in the
organization's words, every rule, and where each came from.
Every decision is recorded in id_policy_decisions, in OPA's decision log
format, labelled with the revision of the policy that made it, and kept 400
days (FG_ID_POLICY_LOG_RETENTION_DAYS); every revision is kept in
id_policy_revisions, so a decision can be read beside its rules.
pnpm policy:log exports them as JSON lines for an audit, and
FG_ID_POLICY_LOG_STDOUT=true also writes each to standard output for a log
drain.
Policy is meant to be seen. Every reason release gives for withholding a
scope is shown on the consent screen, and so is every reason another service's
own policy gives for what it keeps back (ClaimsReview.response.reasons).
People can read the decisions made about them on the account page, or at
/api/v1alpha1/policydecisions. Each service asks its own questions of the
same policy: members' are in apps/members/policies.
- Connect Neon, which sets
DATABASE_URL. The build migrates it: runpnpm build && pnpm migrate, or the distribution's ownpnpm build, which does both. - Set
FG_ID_URLto the domain,FG_ID_NAME,FG_ID_SMTP_URLandFG_ID_EMAIL_FROM, and a group ofFG_ID_SERVICE_*per service. - Keys need nothing: they are created on first use.
A monolith mounting this app must list oidc-provider in its own
serverExternalPackages: oidc-provider relies on its class names, which a
bundle would minify.
/account is where people manage their details, passkeys and services;
/login starts signing in to it. /interaction/<uid> is where signing in to
any service happens, and /sign-out, /signed-out and /error are pages
oidc-provider sends people to. / is a short introduction.
/en/… redirects to the same path without the prefix. A theme cookie of
light or dark selects a variant of every page rendered with that theme on
<html>, and every variant is prerendered. The locale, theme and flags are
declared in lib/indicators.ts and put into the path by
@fairgarden/indicators; link with the Link from lib/link.ts rather than
next/link.
This module releases on its own. 0.1.0-alpha.0 is what main is working towards,
not what is published — the version here is always the next one. Its release
notes are the top section of CHANGELOG.md, where every pull request adds a
line linking itself.
- Publish it. Run the Publish workflow from the Actions tab, picking the dist tag. It refuses if that version is already on npm. Once it is out, open pull requests are held — their changelog check fails — so nothing is noted under a version that has already shipped.
- Start the next version.
pnpm next-versionopens a pull request moving main to0.1.0-alpha.1and starting its section of the changelog, orpnpm next-version --id rcto change identifier. Merging it lifts the hold. A prerelease gets no maintenance branch; there is no released line behind it yet.
A held pull request goes on once it is brought up to date with main and its line is moved into the new version's section.
Every push to main publishes @fairgarden/id@canary. A canary is not a release and
carries no promise; it is there so main can be tried without a checkout.