# Built-in admin login

A self-hosted control plane can verify operators in one of two ways, selected
by `SCRIBASE_AUTH_MODE`:

| Mode | What signs you in | Default on |
|---|---|---|
| `builtin` | The control plane's own password login (argon2id, optional TOTP) | The [single-box layout](https://docs.scribase.com/docs/single-box.md) |
| `oidc` | An external identity provider with RFC 7662 token introspection (`SCRIBASE_OIDC_*`) | Everything else |

Personal access tokens, organization API keys and organization SSO sessions
work in both modes. This page covers `builtin`.

## Create the first administrator

Pick one of two options.

**Option A: setup token (no secret in the env file).** Leave
`SCRIBASE_ADMIN_EMAIL` and `SCRIBASE_ADMIN_PASSWORD_HASH` blank. While no
administrator exists, every boot prints a one-time setup token to the
control-API log. Spend it from the CLI:

```sh
docker compose -p scribase logs control-api | grep -A1 'setup token'
scribase admin setup --email you@example.com --setup-token scb_setup_...
```

or open the console: its sign-in page shows a **Setup token** form instead of
the password form until an administrator exists. Setting only
`SCRIBASE_ADMIN_EMAIL` binds the token to that address.

**Option B: pre-seed the administrator.** Generate a password hash and put it
in the env file:

```sh
scribase admin hash-password        # prompts twice, prints an argon2id PHC string
```

```sh
SCRIBASE_ADMIN_EMAIL=you@example.com
SCRIBASE_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=19456,t=2,p=1$...'
```

Wrap the hash in **single quotes** so Docker Compose keeps every `$`. On boot
the account is created or promoted. Changing the hash later resets that
administrator's password once, on the next boot. That is the break-glass path
if you are locked out.

## Sign in

- **Console**: when the control plane can send email (see
  [Sign in with an email link](#sign-in-with-an-email-link)), the sign-in page
  leads with **Email me a sign-in link**; **Use a password instead** shows the
  password form. After either, an authentication code follows if the account
  has an authenticator app. **Use a recovery code instead** accepts one of the
  one-time recovery codes.
- **CLI**: `scribase login --email you@example.com` prompts for the password
  (echo off, never from arguments) and then for a code if one is enrolled. See
  [Login and tokens](https://docs.scribase.com/docs/cli/login.md).

## Security properties

- **Passwords**: argon2id (19 MiB, t=2), at least 12 characters. An unknown
  account and a wrong password answer the same `401` after the same work.
- **Lockout and rate limits**: 10 failed passwords within 15 minutes lock the
  account for 15 minutes (`SCRIBASE_LOGIN_MAX_FAILURES`,
  `SCRIBASE_LOGIN_LOCKOUT_SECONDS`). Each client address is also limited to 20
  sign-in attempts per 5 minutes.
- **Two-factor**: enroll an authenticator app under **Account > Security**.
  TOTP secrets are sealed with `SCRIBASE_KMS_MASTER_KEY`. Regenerate recovery
  codes on the same page.
- **Sessions**: opaque `scb_ses_` bearers, stored only as SHA-256 digests, with
  an idle timeout (default 2 hours), an absolute timeout (default 12 hours) and
  rotation every 15 minutes (`SCRIBASE_SESSION_IDLE_SECONDS`,
  `SCRIBASE_SESSION_ABSOLUTE_SECONDS`, `SCRIBASE_SESSION_ROTATE_SECONDS`). You can
  list and sign out sessions under **Account > Security > Sessions**.
- **Audit**: every sign-in, failure, lockout, sign-out and password change is
  recorded (`GET /v1/auth/builtin/events`, administrators only).

## Sign in with an email link

When email is configured (webhook or box SMTP, as above), members can sign in
without a password. They enter their email and get a message with a single-use
link and a 6-digit code (for signing in on another device). Both expire in
15 minutes; asking again replaces the earlier ones, and a code allows at most
5 guesses.

- The link opens `SCRIBASE_CONSOLE_URL/auth/magic?token=scb_mgl_...`, which
  only previews the link; the member clicks **Continue** to sign in, so mail
  scanners that open every URL cannot use it up.
- The answer to a request is the same whether or not the address has an
  account. Requests are limited to 5 per 5 minutes per client address and per
  email, plus one email per address per minute.
- A new address gets its account when the link is redeemed, if sign-up is open
  (`SCRIBASE_PUBLIC_SIGNUP`); the organization card check still applies before
  the first project.
- The operator administrator never gets a link: a request for that address
  sends a notice to sign in with the password and second factor instead.
- API: `POST /v1/auth/builtin/magic-link/request` `{"email"}`,
  `.../magic-link/preview` `{"token"}`, `.../magic-link/verify` `{"token"}` or
  `{"email","code"}`. Table: control-plane migration
  `0030_builtin_magic_links.sql`.

## Invite members

An organization owner or admin invites people by email from the console. The
invitee opens the link, sets a password and joins with the invited role. The
normal organization roles (owner, admin, developer, viewer) apply.

## Reset a forgotten password

Reset links are single use and stored only as SHA-256 digests. Creating a new
link replaces the account's earlier unused ones. Setting the new password
clears any lockout and signs out every session of the account. It does not
sign the member in: they sign in normally afterwards, so an authenticator app
still applies.

- **Administrator-issued (always available)**: under **Account > Security >
  Reset a member's password**, the operator administrator enters the member's
  email and their own password, and gets a link valid for 24 hours to share.
  API: `POST /v1/auth/builtin/password-resets` with
  `{"email", "admin_password"}`.
- **Emailed (when a mail relay is configured)**: set
  `SCRIBASE_MAIL_WEBHOOK_URL` to an `https://` relay that accepts
  `{to, subject, text, html}` JSON (bearer `SCRIBASE_MAIL_WEBHOOK_TOKEN`), the
  same relay that emails invitations. The sign-in page then offers **Forgot your
  password?**, which emails a link valid for 1 hour. The answer is the same
  whether or not the email has an account, and each address gets at most one
  email per minute. Without a relay, the page tells the member to ask the
  administrator. Instead of a webhook you can use the box SMTP relay: when
  `SCRIBASE_MAIL_WEBHOOK_URL` is unset and `SCRIBASE_DEFAULT_SMTP_HOST` plus
  `SCRIBASE_DEFAULT_SMTP_ADMIN_EMAIL` are set (the single-box compose passes
  them from `SMTP_*` in `.env`), account email goes out over SMTP (port 465
  implicit TLS, any other port STARTTLS, required).

Links open `SCRIBASE_CONSOLE_URL/reset-password/<token>`, so set
`SCRIBASE_CONSOLE_URL` on the control API. The reset table comes from
control-plane migration `0023_builtin_password_resets.sql`.

The administrator cannot create a link for their own account. If the
administrator is locked out, set a new `SCRIBASE_ADMIN_PASSWORD_HASH` and
restart (see above).

## Console configuration

Build the console with `SCRIBASE_AUTH_MODE=builtin` and give its server
`SCRIBASE_API_INTERNAL_URL` (for example `http://127.0.0.1:8081`). The console
server keeps the session in a `__Host-` cookie (`HttpOnly`, `Secure`,
`SameSite=Lax`) and proxies `/v1` calls, checking origin and a CSRF token on
every mutation. The bearer never reaches browser script. Set
`SCRIBASE_CONSOLE_URL` to the console's public origin. For plain-HTTP local
development only, set `SCRIBASE_CONSOLE_INSECURE_COOKIES=1`.

## Automation

A CLI session ends with the timeouts above. For CI and scripts, create a
personal access token with `scribase token create` or an organization API key
(see [Access tokens & API keys](https://docs.scribase.com/docs/api/tokens.md)).
