Skip to main content

Bootstrap

A tenant, its first user and its signing key are created by the server's seed command — the only way to create the first of anything, since the admin API needs an administrator who needs a tenant. Everything after that first row has two more doors: the admin API, and, for a client on a tenant that opens registration to it, dynamic client registration. This document seeds, because a seed command is reproducible from a shell with no token in it; the admin paths walks the same ground through the API.

Pick how you are running it​

Run it from a checkout describes three ways to run this: everything in Docker, Postgres in Docker with the server on your host, and everything on your host against a PostgreSQL you operate. They differ in exactly one thing this document depends on — how the seed command is invoked — because the container runs a built bundle and a host run has the source.

So the rest of this document calls the CLI through a shell function named odudu. Define it once for the way you are running, from the repository root, and every command below is the same command in all three.

Everything in Docker. The CLI is the bundle inside the running container, and the configuration both it and the server read is infra/docker/.env; the repository-root .env plays no part.

cd infra/docker && docker compose up -d --build
until curl -fsS http://localhost:3000/health/ready; do sleep 2; done
cd ../..
odudu() { docker compose -f infra/docker/compose.yaml exec -T odudu node dist/main.js "$@"; }
{"status":"ok","checks":{"database":"ok"}}

Server on your host, against either Postgres. Node runs the TypeScript source directly, and --env-file hands it the repository-root .env — the same file the dev script loads, so the CLI and the server it is seeding for read one configuration.

pnpm --filter @odudu/server dev # leave running in another terminal
until curl -fsS http://localhost:3000/health/ready; do sleep 2; done

odudu() { node --env-file=.env apps/server/src/main.ts "$@"; }
{"status":"ok","checks":{"database":"ok"}}

What else the choice settles:

What it settlesEverything in DockerServer on your host
What the CLI executesthe bundle built into the imagethe TypeScript source, types stripped by Node
Configuration the server and the CLI both readinfra/docker/.envthe repository-root .env
Base URL of every endpointhttp://localhost:3000http://localhost:3000
PostgreSQLcompose's, published on port 5442compose's on 5442, or one you run yourself

The base URL is the same in all three, so no URL below is mode-specific: compose publishes 127.0.0.1:3000:3000, and a host run defaults to the same port. Run only one of them at a time — they compete for it.

The readiness loop is not decoration, in either mode. docker compose up -d returns when the container has started, which is before it has finished applying migrations, and the dev script returns the shell immediately for the same reason. The seed command does not run migrations; it expects a schema the server put there on boot, and a seed run in that gap fails with relation "tenants" does not exist.

Running everything on your host needs the database and the restricted serving role to exist before any of this — one CREATE DATABASE and one CREATE USER, in Run it from a checkout, against a PostgreSQL whose owner role can CREATE ROLE. Nothing after that bootstrap differs from the second column above. The no-Docker run below used a throwaway cluster made for it — initdb -D /tmp/odudu-pg -U postgres, started with pg_ctl on port 5433, PostgreSQL 18.6 — rather than the compose stack, so that "no Docker at all" meant it.

The key that has to be the same on both sides​

ODUDU_KEK wraps each tenant's private signing key. It is read from whichever configuration the process holds, so the thing that seeded a tenant and the thing serving it must hold the same value — and the two files ship different ones: infra/docker/.env.example carries a throwaway key, and the root .env.example leaves it empty for you to generate.

Seeding under compose and then serving from your host is the easy accident: both processes are pointed at the same database, and only the key differs. It does not fail where you would look for it. Discovery and JWKS answer 200, /authorize renders the login form, the login POST returns a code — and then:

HTTP/1.1 500 Internal Server Error
content-type: application/json; charset=utf-8

{"statusCode":500,"error":"Internal Server Error","message":"Unsupported state or unable to authenticate data"}

(That response is from a host run serving the tenant the compose stack had seeded, with the root .env holding a key generated for it rather than the stack's.)

That is the private key failing to unwrap, and it names neither the key nor the reason. Before seeding anything, copy the ODUDU_KEK line from infra/docker/.env into the root .env if you intend to move between the two. Changing the value afterwards strands every key already wrapped with the old one — a tenant seeded under one key cannot be served under another, and there is no rotation path yet.

A public client, with a user​

A public client has no secret. It proves itself with PKCE alone, which is what a browser or mobile application should be.

odudu seed \
--tenant demo --client demo-spa \
--redirect-uri http://localhost:8080/callback \
--user ada --password correct-horse-battery --email ada@example.com
{
"created": true,
"tenant": "demo",
"tenantId": "01a0cb07-…",
"clientId": "demo-spa",
"userSubjectId": "01a0cb07-…"
}

(tenantId and userSubjectId are generated identifiers; shortened here. userSubjectId is present whenever --user names one, whether this run created it or found it already seeded — Address verification below is what it is for.)

--redirect-uri may be repeated. It is matched by exact string comparison when a request arrives — no trailing-slash tolerance, no case folding, no ignoring the query string, because every normalization widens what an attacker can aim at. --email is optional; without it the email and email_verified claims are omitted together rather than an empty address being asserted with a verification status.

Seeding asserts a whole desired state, so a re-run with identical arguments reports that it changed nothing:

odudu seed \
--tenant demo --client demo-spa \
--redirect-uri http://localhost:8080/callback \
--user ada --password correct-horse-battery --email ada@example.com
{
"created": false,
"tenant": "demo",
"tenantId": "01a0cb07-…",
"clientId": "demo-spa",
"userSubjectId": "01a0cb07-…"
}

while a re-run that disagrees with what is stored refuses rather than overwriting or silently ignoring the difference:

odudu seed \
--tenant demo --client demo-spa \
--redirect-uri http://localhost:8080/callback \
--user ada --password wrong-password --email ada@example.com
OduduError: user ada already exists with a different password
code: 'seed_conflict'

(exit status 1; the Node stack trace between those two lines is elided.)

A confidential client​

A confidential client has a secret and is registered for exactly one way of presenting it. --token-endpoint-auth-method picks that way; omitted, it is client_secret_basic.

odudu seed \
--tenant demo --client demo-backend --client-secret demo-backend-secret \
--token-endpoint-auth-method client_secret_basic \
--redirect-uri http://localhost:8080/callback
{ "created": true, "tenant": "demo", "tenantId": "01a0cb07-…", "clientId": "demo-backend" }
odudu seed \
--tenant demo --client demo-post --client-secret demo-post-secret \
--token-endpoint-auth-method client_secret_post \
--redirect-uri http://localhost:8080/callback
{ "created": true, "tenant": "demo", "tenantId": "01a0cb07-…", "clientId": "demo-post" }

A confidential client is also given a service-account subject, which is what makes client_credentials possible for it (Path C). A public client gets none, and asking for that grant with one is refused.

The method is registered, not negotiated: demo-post must send its secret in the body and demo-backend must send it in the Authorization header. Each is refused if it uses the other's method, even with the right secret — see the /token table in The branches.

Both seed runs above create the tenant's first signing key if it has none. --user and --password go together or not at all, and --email needs a user to belong to. Seeding never adds a user to a client that already exists — it would exit 0 having done nothing, which is worse than refusing.

Two caveats on the command itself. --client-secret and --password are plain command-line flags, so they land in ps output and shell history: that is acceptable for a local bootstrap run by an operator who already controls the machine, and is not something to carry into CI. And the seeded values above are demonstration values in this document; choose your own.

The shell variables the rest of this document uses​

Every command below is a real command, and several need a value produced by an earlier one — $CODE, $VERIFIER, $ACCESS_TOKEN. Paste this into the shell you are working in and they are all defined. It is Path A, run without a browser: the browser's only job in that flow is to follow a redirect and submit a form, and curl does both.

BASE=http://localhost:3000/tenants/demo/protocol/openid-connect
LOGIN=http://localhost:3000/tenants/demo/login-actions/authenticate

VERIFIER=$(openssl rand -hex 32)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 \
| openssl base64 | tr '+/' '-_' | tr -d '=')

AUTH_SESSION_ID=$(curl -sS --get \
--data-urlencode 'response_type=code' \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'scope=openid profile email' \
--data-urlencode 'state=xyz-123' \
--data-urlencode 'nonce=n-0S6_WzA2Mj' \
--data-urlencode "code_challenge=$CHALLENGE" \
--data-urlencode 'code_challenge_method=S256' \
"$BASE/auth" | sed -n '/name="auth_session_id"/{s/.*value="\([^"]*\)".*/\1/p;q;}')

CODE=$(curl -sS -D - -o /dev/null \
--data-urlencode "auth_session_id=$AUTH_SESSION_ID" \
--data-urlencode 'username=ada' \
--data-urlencode 'password=correct-horse-battery' \
"$LOGIN" | sed -n 's/.*[?&]code=\([^&[:space:]]*\).*/\1/p' | tr -d '\r')

TOKENS=$(curl -sS \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=$CODE" \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode "code_verifier=$VERIFIER" "$BASE/token")

ACCESS_TOKEN=$(printf '%s' "$TOKENS" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
ID_TOKEN=$(printf '%s' "$TOKENS" | sed -n 's/.*"id_token":"\([^"]*\)".*/\1/p')
REFRESH_TOKEN=$(printf '%s' "$TOKENS" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')

Two things to know before reusing it. A $CODE is spent by the successful redemption above and lives only 60 seconds anyway, so every experiment on /token that needs an unspent one needs the first three blocks run again. And $AUTH_SESSION_ID is consumed by a successful login, so the same goes for experiments on the login form.

infra/docker/smoke.sh is the same sequence with assertions after each step; read it if you would rather have something that fails loudly. It brings the compose stack up and tears it down again, volumes included, so it is the all-Docker way of running whichever one you picked above — and it will take port 3000 and the stack's database with it.

Dynamic client registration​

This section runs against a tenant of its own, so nothing it registers lands in demo. seed tenant creates one with every setting at its default:

odudu seed tenant --name reg-demo
{ "command": "tenant", "created": true, "tenant": "reg-demo", "tenantId": "01a0cb08-2747-…" }
curl -sS -o /dev/null -w '%{http_code}\n' \
http://localhost:3000/tenants/reg-demo/.well-known/openid-configuration
200

Registration accepts, beside the redirect and authentication metadata the transcripts below register, RFC 7591's client_uri, policy_uri and tos_uri (absolute https, or http on a loopback host, no fragment — the consent screen links them, The client's own pages) and OpenID Connect Dynamic Client Registration §2's id_token_signed_response_alg (an algorithm a non-retired key of the tenant produces), default_max_age and require_auth_time; a response echoes each one registered, and none that was not. No transcript here registers them; integration tests do.

client_registration_policy is disabled on every tenant by default (ADR 0026). Registering before it is opened, or against a tenant that does not exist, answers the same way — an enumeration oracle costs nothing to close here, the same reasoning /token and /userinfo apply to a disabled tenant. The discovery 200 above is what tells the two apart: the first refusal below is the closed policy, not an absent tenant.

curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
http://localhost:3000/tenants/reg-demo/clients-registrations/openid-connect \
-H 'content-type: application/json' \
-d '{"redirect_uris":["https://rp.example/cb"]}'
404
curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
http://localhost:3000/tenants/no-such-tenant/clients-registrations/openid-connect \
-H 'content-type: application/json' \
-d '{"redirect_uris":["https://rp.example/cb"]}'
404

Opening it is a tenant setting like any other:

odudu seed tenant --name reg-demo --set client_registration_policy=open
{
"command": "tenant",
"created": false,
"tenant": "reg-demo",
"tenantId": "01a0cb08-2747-…",
"settings": ["client_registration_policy"]
}

Discovery now advertises the endpoint, and any request registers a client — the open policy is RFC 7591 §3.1's anonymous case. The server assigns client_id; a client_secret is generated for a confidential client (the default — token_endpoint_auth_method defaults to client_secret_basic) and returned exactly once, in this response. clients.secret_hash stores its Argon2id hash; there is no way to retrieve the plaintext again.

curl -sS -X POST http://localhost:3000/tenants/reg-demo/clients-registrations/openid-connect \
-H 'content-type: application/json' \
-d '{"redirect_uris":["https://rp.example/cb"],"client_name":"Example RP"}'
{
"client_id": "01a0cb08-2958-…",
"client_id_issued_at": 1790112704,
"client_secret": "BvE93j7HNH4luJx0BBMi9k5sPlUXNafEsyCQPKkEPRM",
"client_secret_expires_at": 0,
"redirect_uris": ["https://rp.example/cb"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "client_secret_basic",
"client_name": "Example RP"
}

This registration presented no credential, so registration_origin is 'anonymous' and consent_required defaults true on the row it wrote (ADR 0027) — an anonymous registrant is not the operator vouching for a client the way seed client or a token-authorized registration is.

The token policy is stricter: a request with no bearer credential is refused before anything is validated, with WWW-Authenticate naming the scheme and no error parameter (RFC 6750 §3.1's distinction between an absent credential and a rejected one).

odudu seed tenant --name reg-demo --set client_registration_policy=token
curl -sS -D - -o /dev/null -X POST \
http://localhost:3000/tenants/reg-demo/clients-registrations/openid-connect \
-H 'content-type: application/json' -d '{"redirect_uris":["https://rp.example/cb"]}' \
| grep -iE '^HTTP|www-authenticate'
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="client-registration"

seed registration-token mints the credential a registrant presents: --uses bounds how many registrations it is good for, --ttl its lifetime in seconds, and both are required since the schema has no default for either. Every other seed subcommand answers with a line of JSON; this one answers with the token alone, so TOKEN=$(odudu seed registration-token …) captures exactly the credential and nothing else. The admin API mints the same way, for an operator who would rather not shell into the container: POST /admin/tenants/{tenant}/registration-tokens (manage-clients), documented in the admin paths.

TOKEN=$(odudu seed registration-token --tenant reg-demo --uses 1 --ttl 3600)
j4B_nsJYIJSe8AgjA9xVsFU28J6L67yYzVinAYXXP4w

It is stored as its SHA-256 digest, the same shape the account action tokens use, and spent by one UPDATE … RETURNING so two concurrent registrations against a one-use token cannot both win — answering the request and consuming the token happen in the one transaction that inserts the client, never earlier. Presenting it registers a client whose registration_origin is 'token', with consent_required defaulting false: an initial access token is an operator's own authorization, as much as seed client naming a client directly is.

curl -sS -X POST http://localhost:3000/tenants/reg-demo/clients-registrations/openid-connect \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"redirect_uris":["https://rp2.example/cb"],"token_endpoint_auth_method":"none"}'
{
"client_id": "01a0cb08-2c63-…",
"client_id_issued_at": 1790112705,
"redirect_uris": ["https://rp2.example/cb"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "none"
}

token_endpoint_auth_method: "none" names a public client, so no secret is generated or returned — RFC 6749 §10.1 gives no client that cannot keep one confidential a credential to keep.

A tenant at its max_clients cap refuses further registration with 403 and invalid_client_metadata, taken under SELECT … FOR UPDATE on the tenant row before the count so two concurrent registrations cannot both observe room that only one of them will actually get.

A registered backchannel_logout_uri is stored, echoed back in the registration response, and now read: discovery advertises backchannel_logout_supported and its front-channel twin for every tenant (see discovery above), and ending a session delivers to it (see front-channel and back-channel logout below). userinfo_signed_response_alg is stored, echoed, and now read: a client that registers RS256 or ES256 gets a signed /userinfo response, and one that registers none gets a JWT /userinfo response that is not signed — an RFC 7519 §6 unsecured JWT (alg: "none", no signature). Both are application/jwt and carry iss and aud (OIDC Core §5.3.2 for the "If signed" case; OIDC Registration §2 and Discovery §3 for why none still serializes as a JWT at all — see the OIDC Core reading note). Registration refuses a value the tenant's own active signing key cannot produce, and discovery's userinfo_signing_alg_values_supported is that same tenant's answer — [key.alg, "none"] — never a fixed pair advertised to every tenant regardless of which key it actually holds (a tenant holds exactly one). userinfo_encrypted_response_alg and _enc are stored, echoed and now read too — see Encrypted and nested UserInfo responses below for the transcript.

The metadata checks below are shown anonymously, so the policy goes back to open first — under token every one of them would answer the 401 above before any metadata was read, and would demonstrate the credential rather than the check:

odudu seed tenant --name reg-demo --set client_registration_policy=open
{
"command": "tenant",
"created": false,
"tenant": "reg-demo",
"tenantId": "01a0cb08-2747-…",
"settings": ["client_registration_policy"]
}

A non-HTTP redirect_uri has to look like RFC 8252 §7.1's reverse-DNS custom scheme (ADR 0032): the scheme names at least one ., which is what tells com.example.app:/cb apart from javascript:, data: and file: without an enumerable denylist of dangerous ones. A dotless custom scheme — common in the wild, and otherwise harmless — is refused all the same:

curl -sS -X POST http://localhost:3000/tenants/reg-demo/clients-registrations/openid-connect \
-H 'content-type: application/json' -d '{"redirect_uris":["myapp://cb"]}'
{
"error": "invalid_redirect_uri",
"error_description": "redirect_uris entry myapp://cb is not valid"
}
curl -sS -X POST http://localhost:3000/tenants/reg-demo/clients-registrations/openid-connect \
-H 'content-type: application/json' -d '{"redirect_uris":["com.example.app:/cb"]}'
{
"client_id": "01a0cb08-2e42-…",
"client_id_issued_at": 1790112706,
"client_secret": "of3NqFMwFsEOjqcvz46q-L18sz4EwP2Cge7LtyRRr8A",
"client_secret_expires_at": 0,
"redirect_uris": ["com.example.app:/cb"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "client_secret_basic"
}

A client holds at most 200 redirect URIs, the bound every list on a client shares (the admin paths); a longer list is refused with the count and the limit, and 200 exactly is registered. In the tenant client-reach-demo, with registration opened:

curl -sS -X POST http://localhost:3082/tenants/client-reach-demo/clients-registrations/openid-connect \
-H 'content-type: application/json' -d @reg-over.json

reg-over.json held 201 URIs, https://rp.example/cb/0 to …/cb/200:

{"error":"invalid_redirect_uri","error_description":"redirect_uris holds 201 entries, at most 200"}

frontchannel_logout_uri gets the same https/absolute/no-fragment policy backchannel_logout_uri already had — it is the src of an iframe the logout page now renders (front-channel and back-channel logout), the sink a bare http: or javascript: value would otherwise reach:

curl -sS -X POST http://localhost:3000/tenants/reg-demo/clients-registrations/openid-connect \
-H 'content-type: application/json' \
-d '{"redirect_uris":["https://rp.example/cb"],"frontchannel_logout_uri":"http://rp.example/fc"}'
{
"error": "invalid_client_metadata",
"error_description": "frontchannel_logout_uri must be an absolute https URI with no fragment"
}

Front-Channel Logout 1.0 §2 also requires a registered frontchannel_logout_uri's domain, port and scheme to match one of the client's own redirect_uris — an unauthenticated inbound surface is only as trustworthy as an origin the client already proved it controls. Matching any one of several registered redirect URIs satisfies it; a client with none registered has nothing to match against and is refused the same way:

curl -sS -X POST http://localhost:3000/tenants/reg-demo/clients-registrations/openid-connect \
-H 'content-type: application/json' \
-d '{"redirect_uris":["https://rp.example/cb"],"frontchannel_logout_uri":"https://evil.example/fc"}'
{
"error": "invalid_client_metadata",
"error_description": "frontchannel_logout_uri must share its domain, port and scheme with a registered redirect_uri"
}

frontchannel_logout_session_required is accepted alongside it, stored and echoed back the same way backchannel_logout_session_required already is, defaulting to false:

curl -sS -X POST http://localhost:3000/tenants/reg-demo/clients-registrations/openid-connect \
-H 'content-type: application/json' \
-d '{"redirect_uris":["https://rp.example/cb"],"frontchannel_logout_uri":"https://rp.example/fc","frontchannel_logout_session_required":true}'
{
"client_id": "01a0cb08-2e9a-…",
"client_id_issued_at": 1790112706,
"client_secret": "cznVu4B8j-I63JVMDPbEUePpMSyV8EvDiwhOD0HR7Pk",
"client_secret_expires_at": 0,
"redirect_uris": ["https://rp.example/cb"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "client_secret_basic",
"frontchannel_logout_uri": "https://rp.example/fc",
"frontchannel_logout_session_required": true
}

A registered jwks carries public keys only. A key holding any private member — d, p, q, dp, dq, qi or k — is refused, on registration here and on POST and PATCH /admin/tenants/{tenant}/clients alike, since all three validate through the same client metadata check: a stored private half would be handed back by every read and export of the client. These three calls are from a different stack, published on port 3080, in a tenant jwks-demo created through POST /admin/tenants with client_registration_policy set to open through PATCH /settings, so the refusal is the key's and not the policy's. A private key is refused by registration and by the admin API, and the first key without its private member then registers:

curl -sS -X POST http://localhost:3080/tenants/jwks-demo/clients-registrations/openid-connect \
-H 'content-type: application/json' \
-d '{"redirect_uris":["https://rp.example/cb"],"token_endpoint_auth_method":"private_key_jwt","jwks":{"keys":[{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","d":"jpsQnnGQmL-YBIffH1136cspYG6-0iY7X1fCE9-E9LI"}]}}'
curl -sS -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"client_id":"signer-app","grant_types":["client_credentials"],"token_endpoint_auth_method":"private_key_jwt","jwks":{"keys":[{"kty":"oct","k":"c3ltbWV0cmljLXNlY3JldA"}]}}' \
http://localhost:3080/admin/tenants/jwks-demo/clients
curl -sS -X POST http://localhost:3080/tenants/jwks-demo/clients-registrations/openid-connect \
-H 'content-type: application/json' \
-d '{"redirect_uris":["https://rp.example/cb"],"token_endpoint_auth_method":"private_key_jwt","jwks":{"keys":[{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"}]}}'
{"error":"invalid_client_metadata","error_description":"jwks.keys[0] carries the private member d; register public keys only"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"jwks.keys[0] carries the private member k; register public keys only","errors":[{"path":"jwks","message":"jwks.keys[0] carries the private member k; register public keys only"}],"instance":"01a0ea63-bbfe-7bcd-9234-5a96705e5f51"}
{"client_id":"01a0ea63-bc2e-76fa-b646-56b5121f644e","client_id_issued_at":1790638799,"client_secret":"_YZPORl5uy3NcYXEzMq8PSjdCnddn4XutbW7T4-QR4Y","client_secret_expires_at":0,"redirect_uris":["https://rp.example/cb"],"grant_types":["authorization_code"],"token_endpoint_auth_method":"private_key_jwt","jwks":{"keys":[{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"}]}}