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"}'
⚠ 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.