How to create the first administrator of a fresh d-oauth-server install, how to
replace that bootstrap account immediately, and how to respond to a full
database disclosure. There is no HTTP bootstrap endpoint: provisioning is
CLI/Job only.
Task 48 owns
SECURITY.md; this file is the task-47 operator runbook. The threat model and the replace-immediately rationale live there.
d-oauth-server migrateApply the PII envelope-encryption expansion
(migrations/1791417600_pii_encryption.sql) during a maintenance window with
the app tier quiesced, from the dedicated migration Job:
d-oauth-server migrate
ALTER TABLE ... DROP NOT NULL takes ACCESS EXCLUSIVE on users,
redirect_uris, and oauth_clients, blocking reads and writes on each table for
the duration. Each non-concurrent CREATE INDEX in the file takes SHARE on its
table, blocking writes. A non-transactional CREATE INDEX CONCURRENTLY is not
used because sqlx applies every migration inside one transaction, and
PostgreSQL rejects CONCURRENTLY in a transaction. The migration is additive:
no column is dropped or renamed and the legacy unique constraints are kept, so
version N-1 replicas keep working through the rollout.
Interactive (password read from stdin, never argv, never logged):
d-oauth-server admin bootstrap --email ops@example.com
Non-interactive / cluster Job (mounted secret file):
d-oauth-server admin bootstrap --email ops@example.com \
--password-file /run/secrets/bootstrap-password
When neither a file nor stdin yields a password, the command generates a
256-bit password and prints it exactly once, and only to a TTY. If stdout is
not a TTY the command refuses instead of risking the secret in Job logs:
re-run attached to a terminal or supply --password-file.
A second admin bootstrap refuses while any enabled admin exists:
d-oauth-server admin bootstrap --email second@example.com
# error: already exists: an administrator already exists (1); ...
The override requires explicit --force, prints a loud warning to stderr,
and emits an audited admin.bootstrap.force event. Prefer routine
provisioning instead:
d-oauth-server admin bootstrap --email second@example.com --force
d-oauth-server user create --admin --email named-admin@example.com
The bootstrap account starts with must_change_password=true and
totp_enabled=false. Its first login yields a restricted session that can
only change the password and enroll TOTP — /authorize, /oauth/*, and
admin-gated actions deny it until both steps complete.
/login/change-password.must_change_password and stamps password_changed_at./login/totp/setup and confirm with an authenticator
code. The session is re-issued with full authority only now.d-oauth-server user create --admin --email <name>@example.comd-oauth-server user disable --email ops@example.com/run/secrets/bootstrap-password material and revoke any copy of a
generated one-time password.On the audit target (never carrying secrets): admin.bootstrap.created
and admin.count.changed on success, admin.bootstrap.refused on refusal,
admin.bootstrap.force on a forced override. First-login, password-changed,
and TOTP-enrolled outcomes are emitted by the login flow itself.
migrate seeds zero users
and serve never creates one.Use this when a PostgreSQL dump, backup, or read replica has left your control
on unknown terms. The blast radius and the rationale for the order below are in
docs/SECURITY.md; this is the operator checklist. Preserve
evidence first: capture the audit stream and keep the X-Request-Id
correlation. No step below writes a secret to a log.
D_OAUTH_TOKEN_PEPPER / D_OAUTH_TOKEN_PEPPER_FILE,
D_OAUTH_TOTP_ENCRYPTION_KEY / D_OAUTH_TOTP_ENCRYPTION_KEY_FILE); if it
is not provably untouched, assume it leaked and continue.D_OAUTH_TOKEN_PEPPER (or
D_OAUTH_TOKEN_PEPPER_FILE) at a new value, bump
D_OAUTH_TOKEN_PEPPER_ID, and perform a rolling restart. Dropping the old
id invalidates every live opaque access and refresh token, so clients
re-authenticate.invalid_grant for a token minted before the rotation.D_OAUTH_TOTP_ENCRYPTION_KEY (or D_OAUTH_TOTP_ENCRYPTION_KEY_FILE) at a
new value, bump D_OAUTH_TOTP_KEY_ID, and roll the replicas. Retain the
old key only until every user has re-enrolled.d-oauth-server user enroll-totp --email <email>
(prints the secret and otpauth:// URI exactly once).[ ] 3. Rotate every confidential client secret. Read the current metadata,
then re-submit it through RFC 7592 PUT /register/{client_id} to mint a
new secret and a new registration token:
curl -sS https://<issuer>/register/<client_id> \
-H "Authorization: Bearer <current-registration-access-token>"
curl -sS -X PUT https://<issuer>/register/<client_id> \
-H "Authorization: Bearer <current-registration-access-token>" \
-H 'Content-Type: application/json' \
--data @client-metadata.json
The PUT response carries client_secret and
registration_access_token exactly once. Store both before the process
exits; the previous registration token immediately answers 403.
[ ] 4. Rotate every DCR registration token. Every successful PUT above
already rotated the registration token. If you also need new
registrations after the breach, issue a fresh single-use initial-access
token:
d-oauth-server initial-access-token issue --uses <n> --ttl <seconds>
(the raw token is printed once).
[ ] 5. Verify fail-closed behavior. Readiness must report ready only with
both dependencies up:
d-oauth-server healthcheck --url http://127.0.0.1:8081/health/ready.
A dependency outage during recovery must yield 503, never a fail-open
success.