Skip to content

Sign-in

The routes that issue a credential. Authentication covers the two credential shapes and how to send one; this page covers how you get one in the first place.

There are three ways in, and they all end at the same place — an ordinary session row that is listable and revocable:

Route in Who uses it What it produces
Emailed sign-in code People, in a browser A browser session
Browser-authorized CLI mecca login on a machine A CLI session (24 h idle window)
Machine token exchange The appmecca admin CLI A CLI session for platform-root

⚠ There is no scoped or limited token anywhere in this surface. Every route below mints a full session carrying the account's own scope. Anything that says otherwise is wrong.


Emailed sign-in code

Two POSTs. The first sends a six-digit code and a link carrying that same code; the second redeems either one.

POST /api/auth/code/request

curl -X POST https://api-url/api/auth/code/request \
  -H 'Content-Type: application/json' \
  -d '{"email": "you@example.com"}'
{ "sent": true, "expires_in": 600 }

⚠ The response is byte-identical for an address that exists, one that does not, and one that has not been invited. That is deliberate: any variation — a differing field, a count, a 403 — turns an unauthenticated route into an account-enumeration oracle. A caller cannot learn from this endpoint whether an account exists; only the recipient of the email learns anything.

The response also sets an httpOnly __Host-am_signin cookie. That cookie binds the typed path to the browser that asked (see below).

Rate limits are per-IP and per-email. The per-email leg is not decoration: one live code per address (enforced by a partial UNIQUE index), five attempts per code, and a cap on how fast a new code can be minted are together what make six digits sufficient. Loosen any one and the digit count has to be re-derived.

POST /api/auth/code/verify

curl -X POST 'https://api-url/api/auth/code/verify' \
  -H 'Content-Type: application/json' \
  -d '{"email": "you@example.com", "code": "048231"}'
{
  "session": { "token": "ses_…", "expires_at": "…", "absolute_max_at": "…" },
  "user": { "…": "…" },
  "memberships": [],
  "created": false
}

created is true when this sign-in created the account. Signing in is verifying — redeeming a code proves control of the address — so a new account is created already email-verified and an existing unverified one is promoted.

Query parameter link=true marks the one-click path from the email, which does not require the nonce cookie. The typed path (default) does.

⚠ Why the two paths differ. A code you type was requested by the browser you are typing into, so the cookie binds them and a code relayed to you by an attacker will not verify. A link may legitimately be opened on a different device from the one that asked — you request on a laptop and read mail on a phone — so requiring the cookie there would break the flow's whole point.

⚠ Every failure returns the same 401 and the same message — wrong code, expired code, no live code, attempts exhausted, nonce mismatch. Distinguishing them would be the enumeration oracle the request path carefully avoids, reached more slowly.

⚠ The page behind the emailed link must require a click before calling this. Verification is a POST for exactly that reason: an email security appliance that prefetches links would otherwise burn the code before the human ever saw the page — and the symptom ("the code never works") is close to undiagnosable from a support ticket.


Browser-authorized CLI

A device-authorization flow. The CLI cannot host a callback — it routinely runs over SSH and inside containers, where a loopback redirect is dead — so it starts a request, prints a confirmation code, opens a browser, and polls.

mecca login you@example.com
  │  mints a verifier, sends its S256 challenge + a machine label
  ├─► POST /api/auth/cli/authorize   → {request_id, confirm_code, verify_uri, expires_at, interval}
  │   prints the confirm code, opens the browser
  │
  │   browser at verify_uri → sign in if needed → the AUTHORIZE screen
  │
  └─► POST /api/auth/cli/token       (polled)  → {session, user}

POST /api/auth/cli/authorize

Unauthenticated — the CLI has no credential yet, which is the whole point.

Field Meaning
code_challenge BASE64URL(SHA256(verifier)), padding stripped. S256 only — there is no method parameter
client_label Machine name/OS, 1–120 chars. Rendered on the authorize screen
email Optional, pre-fills the browser's sign-in form. Not an assertion of identity

⚠ request_id alone is never a bearer credential. It travels in a browser URL, so it is in history, in a screen share, and in anything the user pastes into a chat. Only the CLI holds the verifier, so only the CLI that started the flow can redeem the request.

⚠ The confirm code defends against blind authorization. Without it, a user sent a /cli-authorize?request=… link by an attacker authorizes the attacker's CLI while believing it is their own. The browser shows a code; the CLI printed the same code; the human compares them. It is checked server-side on approve — a client-side comparison is a courtesy an attacker's page would not perform.

GET /api/auth/cli/request/{request_id} · POST /api/auth/cli/approve · POST /api/auth/cli/deny

Session-gated; these are what the browser's authorize screen calls. Approve requires an interactive session — an api-key caller is refused 403, because a long-lived machine credential minting interactive sessions is a privilege escalation in shape even where the scopes match.

POST /api/auth/cli/token

Status Meaning What the CLI should do
200 Approved and redeemed Store the session. Single use
428 Not yet approved Keep polling — the only status that means this
403 Denied, expired, or wrong verifier Stop and report
404 No such request Stop and report

⚠ The session is minted at the exchange, never at approve. Session tokens are SHA-256 at rest and the plaintext exists only at mint; minting earlier would force stashing that plaintext in the request row to hand over later — a bearer credential sitting in a table.

The CLI session's window

A CLI session idles out in 24 hours, not the browser's 30 days. The absolute 90-day cap is the same for both.

⚠ The reasoning is about where the credential lives: a browser session sits behind a screen lock on a machine someone is using, while a CLI token is a file on disk that outlives every process, so its unattended window is the exposure. Revoke either from the sessions surface, or with mecca logout.


Machine token exchange

POST /api/auth/machine/session

The appmecca admin CLI's non-password login. A high-entropy token from Secrets Manager (appmecca/{env}/platform-root-token) is exchanged for an ordinary session.

curl -X POST https://api-url/api/auth/machine/session \
  -H 'Content-Type: application/json' -d '{"token": "amt_…"}'

⚠ Exchange, never bear. A standing bearer read from Secrets Manager on every call would put a long-lived credential on the wire for every single appmecca command, and would be revocable only by rotating the secret. The exchange yields a session row that is listable, revocable, and expiring like any other.

⚠ SHA-256 at rest, not bcrypt — and that is the opposite of the sign-in code's reasoning, deliberately. The machine token is 256 bits of randomness, so a slow KDF buys nothing and would be a latency floor on a hot admin path. A six-digit code is 20 bits, which is why it needs a Secrets-Manager pepper instead. The two must not be harmonised in either direction.