Module kit / Identity and auth
CLI login to a scoped key
identity-auth/cli-login-scoped-key
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | prod |
| Reusable | yes |
| Stack | fastapi-0.118, sqlalchemy-sync, postgres, click |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping |
| Frontends | identity-auth/ui/api-keys-settings-and-developer-dashboard |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Also measured on |
|
| Version | 3 |
Purpose
A developer types alchemy auth login, a browser tab opens, they sign in to the Labs SPA as they always do, and the terminal comes back holding an API key it can use on every later call; no key is ever pasted by hand. The invariant that makes it trustworthy is that the trade is a Labs session for a Labs credential and nothing wider: the route asks the same labs grant question as /login before it mints, and the key it writes carries an explicit {"scopes": ["read", "write"]}, because on this platform a key with no scopes is a key with every scope (api/dependencies.py lines 106 to 132, read 2026-09-12 at fd8558f).
When to use
Use it when a terminal, a script on a laptop, or any client that can open a browser but cannot hold a session cookie needs a long-lived credential from a person who already signs in through the web. Trigger phrases: "log the CLI in", "the browser flow", "callback_port", "where does the CLI's key come from", "the terminal is waiting for authentication", "alchemy auth login".
Do not use it for a key a developer manages from a page (identity-auth/developer-api-keys-and-security-policy, B10, which is what the settings card and the developer portal call), for the gateway credential a Prima surface mints (ai-only, B10 and B11), for an API-only account's first key (identity-auth/api-only-self-serve-signup, B14), or for a machine that has no browser at all: the CLI's --no-browser mode pastes a key minted in B10's settings card and touches this route never (cli/alchemy_cli/commands/auth_cmd.py lines 14 to 27).
Stack assumptions
Pinned as measured at fd8558f (2026-09-12): the Labs backend of S4 (fastapi==0.118.2, SQLAlchemy sync sessions through DatabaseSession), S5 for decode_token_with_surface, get_user_by_id, the cookie names and the API-key dependency that later consumes the key, S6 for X-Workspace-ID. The CLI is cli/alchemy_cli/, a click package with its own pyproject.toml; it uses the standard library for the callback server (http.server, socket, webbrowser, secrets) and nothing else.
One assumption the reference does not meet (2026-09-12, fd8558f, Gotchas): the route reads the access_token cookie on the API origin and nothing else, so the recipe requires that the sign-in the SPA performs leaves that cookie in the browser. With the SPA and the API on different origins (labs.alchemy.zuerich and opus.alchemy.zuerich in production, scripts/render-build-frontend.sh line 49; :3000 and :8000 locally, AuthContext.tsx line 64; no Vite proxy in frontend/vite.config.ts) that means S2's session-issuing requests (password login, passkey complete, magic-link verify) are sent with withCredentials: true or credentials: 'include' against a CORS policy that answers allow_credentials=True (app_fastapi.py line 449 already does), or the API is served under the SPA's own origin so the request is same-origin. A non-credentialed cross-origin XHR or fetch discards the response's Set-Cookie, and then this route never sees a session.
| Name | Default | Behaviour |
|---|---|---|
API_BASE_URL | http://localhost:8000 (line 42) | the origin the route embeds in the login redirect so the SPA can send the browser back here |
FRONTEND_URL | http://localhost:3000 (line 43) | the SPA origin of the /login?redirect= hop; the route falls back to the same literal when the value is empty (auth_router.py line 1545) |
ENVIRONMENT | development | decides whether the session cookie the route reads was set Secure: auth_router.py _set_auth_cookies line 61 (secure=is_production, SameSite=Lax, path /api, no Domain) for register, password, refresh, magic-link, OIDC and development-only dev-login sessions; core/session_cookies.py line 77 for passkey sessions only (its cross-site branch, 83 to 87, forces Secure regardless). Nothing else here reads it |
On the CLI: ALCHEMY_API_URL (the API origin; default is the production API host, config.py line 11), ALCHEMY_API_KEY (wins over the credentials file, lines 39 to 45), ALCHEMY_WORKSPACE_ID (wins over the stored workspace, lines 53 to 58). Unset means the file under ~/.alchemy/credentials is read; no file means "Not logged in" and exit 1 on any command that needs a key.
Data model
The route writes one row into the table B10 owns and adds no table of its own:
-- models/api_key_model.py lines 14 to 83; the migration is the baseline
-- (migrations/versions/baseline_prod_schema_001.py lines 501 to 522).
api_keys (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE CASCADE, -- NULL when the user owns no workspace
key_hash varchar(255) NOT NULL, -- sha256 hex of the raw key; UNIQUE INDEX ix_api_keys_key_hash
key_prefix varchar(20) NOT NULL, -- first 12 characters plus "..."
name varchar(255) NOT NULL, -- 'AlchemyCLI on CLI' (the hostname is the literal "CLI", lines 1561 to 1562)
description text, -- 'Auto-created by AlchemyCLI login'
permissions jsonb NOT NULL DEFAULT '{"scopes": ["read"]}', -- written explicitly as {"scopes": ["read", "write"]}
is_active boolean NOT NULL DEFAULT true,
expires_at timestamptz NULL, -- never set by this route: a CLI key does not expire
max_uses, uses_count, allowed_ip_addresses, last_ip_address, anomaly_score,
suspended_until, suspension_reason, force_rotation_at, rate_limit_tier, last_used_at, created_at
);
What is not stored server-side: the raw key (it leaves in the redirect URL once), the state value (echoed back, never persisted), the callback port. What the CLI stores: ~/.alchemy/credentials, a JSON file {"api_key", "email", "workspace_id"} written with mode 0600 (cli/alchemy_cli/config.py lines 18 to 22), and it is the only place the plaintext lives after the redirect.
Two shapes worth naming. The permissions column is NOT NULL with a server default of {"scopes": ["read"]}, so a raw INSERT that omits it gets a read-only key, not an unlimited one, and so does the ORM path when the argument is omitted (the Python-side default, api_key_model.py lines 30 to 35). The unlimited case is any row whose value has no non-empty scopes list, and the ORM writes one in three ways: permissions={}, a dict without a non-empty scopes list, and permissions=None. The last does not fail. The column is JSONB with SQLAlchemy's default none_as_null=False, so an explicit None is rendered as the JSON literal null, a non-NULL jsonb value that satisfies NOT NULL (reproduced 2026-09-12 with the pinned SQLAlchemy==2.0.43, backend/requirements.txt line 109, against the :5434 test Postgres: should_evaluate_none is True, the bind processor turns None into 'null', the commit raises nothing, jsonb_typeof(permissions) prints null and permissions IS NULL prints false; the row reads back as Python None, and (api_key.permissions or {}).get("scopes") or [] at api/dependencies.py line 98 is [], so _enforceable is empty and the key is unlimited, exactly like {}). The comment at admin_router.py lines 2879 to 2884 says why its own call site never passes None; it calls the stored value "SQL NULL", the storage is JSON null, and the effect it warns about is the one measured. Corrected 2026-09-12: version 2 of this recipe said the None path fails with an IntegrityError, which inverted the hazard. And the baseline migration's created_at default is a frozen timestamp literal from the dump (baseline_prod_schema_001.py line 513), while the model's server_default is NOW(); the ORM path this route uses sets created_at from datetime.utcnow, so the literal is reached only by hand-written SQL.
API surface
One route, mounted with the rest of auth_router at /api/auth on ALL_SURFACES (app_fastapi.py line 820), so the gate admits any surface's session and the route itself asks the Labs question.
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
GET /api/auth/cli-login?callback_port=<int>&state=<str> | none declared; the handler reads the access_token cookie itself (auth_router.py line 1531) and ignores Authorization and X-API-Key | the first workspace the user owns, SELECT id FROM workspaces WHERE owner_id = :uid LIMIT 1 (lines 1571 to 1574); X-Workspace-ID is not read | 307 to {FRONTEND_URL}/login?redirect={API_BASE_URL}/api/auth/cli-login?callback_port=..&state=.. when there is no usable cookie (1543 to 1548; the nested URL is a raw f-string, not percent-encoded, so state is a query parameter of /login rather than of redirect, Gotchas); 403 "This account is not permitted to sign in to Alchemy Labs." without a labs grant (_require_labs_grant, 140 to 152, called at 1557); 307 to http://localhost:{callback_port}/callback?api_key=..&email=..&workspace_id=..&state=.. on success (1602 to 1609); on any exception, 307 to http://localhost:{callback_port}/callback?error=<str(e)>&state=.. when a port was given, else 500 "CLI login failed" (1611 to 1616) | RedirectResponse defaults to 307; the 403 is FastAPI's {"detail": ...} reshaped by S7 into the Labs envelope |
The redirect on success is the whole response body: a browser following it hands the raw key to a loopback HTTP server the CLI started. The CLI's contract for that callback (cli/alchemy_cli/auth.py lines 21 to 58): path must be /callback (else 404); state must equal the value it generated (else 400 "Invalid state parameter"); error present means failure; api_key and email are required, workspace_id optional and stored as an empty string when absent.
The key that comes back is then used as X-API-Key on every CLI request, with X-Workspace-ID added when a workspace id is known (cli/alchemy_cli/client.py lines 38 to 44); it authenticates through S5's _authenticate_via_api_key and is subject to B10's method matrix, security checks and prefix lock like any other key.
Application logic
cli_login (auth_router.py lines 1510 to 1616), in order, with the CLI's half interleaved where it runs:
- CLI (
browser_login,auth.py85 to 116): bind a free port on127.0.0.1(_find_free_port, 79 to 82), generatestate = secrets.token_urlsafe(32), startHTTPServerwith the handler'sexpected_stateset, print the login URL and callwebbrowser.openon{ALCHEMY_API_URL}/api/auth/cli-login?callback_port={port}&state={state}. - Route: read the
access_tokencookie. If present,auth_service.decode_token_with_surface(token, token_type="access")andget_user_by_id(sub); any exception is swallowed and leavesuser = None(1533 to 1541). The surface the token was minted for is read and discarded. - No user: redirect to the SPA's
/loginwithredirect=set to this route's own URL, query included (1543 to 1548). The SPA (LoginAlchemy.tsx) readsredirectfrom the query (line 16); after a password login it setswindow.location.hrefto it (178 to 182), after a passkey loginwindow.location.replace(82, 127); an already-signed-in visitor is sent there at once (94 to 100). The magic-link path keeps only relativeredirectvalues (150), so a sign-in by mail lands on/dashboardand the developer re-opens the printed URL by hand. At fd8558f this hop does not complete the flow, for two independent reasons (Gotchas): the nested URL is not percent-encoded, sosearchParams.get('redirect')returns.../cli-login?callback_port=<port>andstateis left behind as a parameter of/login; and the SPA's password, passkey and magic-link requests are not credentialed against the API origin, so the sign-in stores noaccess_tokencookie there. The browser comes back to this route either without a cookie (step 3 again, without end, for a visitor the SPA already knows) or, when a cookie from an earlier OIDC sign-in exists, withstate="", which the CLI refuses in step 9. - User found:
_require_labs_grant(user)(1557). It reads the user's grant rows (get_user_surfaces_standalone,services/surface_service.py128 to 132, overuser_surface_grantsonly) and not the token: an account without alabsgrant (an api_only account, or one whose grant was revoked) stops here with 403 and never mints, while alabs-granted user who presents aprima_websession cookie passes, because the surface read in step 2 is discarded. The invariant is "no mint without the grant", not "no mint from a Prima session" (Gotchas); the comment at 1550 to 1556 says why the gate downstream is not enough. - Mint:
raw_key = "prima_pk_" + secrets.token_urlsafe(32)(GATEWAY_KEY_PREFIXimported fromservices/primaapi/identity.pyline 59, one prefix for the whole product since 0b2519e, 2026-08-03),key_hash = sha256(raw_key),key_prefix = raw_key[:12] + "..."(1565 to 1567). - Workspace: the first row of
workspaces WHERE owner_id = :uid LIMIT 1, with noORDER BY(1571 to 1574);workspace_idis that id orNone, and the string sent to the CLI is that id or"". - Insert the
APIKeyrow withpermissions={"scopes": ["read", "write"]}andis_active=True,db.commit()(1585 to 1597). Nothing is enqueued to Prima: the key has noaiscope, so it is not a gateway key (B12'sis_gateway_key). - Redirect to
http://localhost:{callback_port}/callbackwithapi_key,email,workspace_idandstateURL-encoded (1602 to 1609). - CLI: the handler checks
state(a mismatch, including the emptystate=a lost value produces, is 400Invalid state parameter. Please try again.and leavesresultunset), reads the three values, answers the browser with a static "Login successful" HTML page, andbrowser_loginreturns the dict;loginwrites~/.alchemy/credentialswith mode0600and prints the email and the first twelve characters of the key (auth_cmd.py25 to 28). At fd8558f the success branch is reached only when the browser already held the API-origin cookie before the CLI opened it, so no/loginhop happened. - On any exception after step 4 the route logs it and redirects the browser to the callback with
error=<exception text>; the CLI turns that intoclick.ClickException("Login failed: ...")(auth.py113 to 114).
Invariants
- No mint without the Labs question:
_require_labs_grantruns before theAPIKeyconstructor on every path that reaches it (1557 before 1585), and it readsuser_surface_grantslive throughget_user_surfaces_standalone, the same reader/loginuses (S5). - The key is never unscoped:
permissionsis written as a literal{"scopes": ["read", "write"]}(1593). A rebuilt route that writes{}, a dict without a non-emptyscopeslist, or an explicitNoneproduces an unlimited key (S5 Invariants, "an empty scope list means unlimited"); theNonecase is stored as JSONnull, passesNOT NULLand fails nothing (Data model), so the only safe writes are a literal non-emptyscopeslist, or no argument at all, which yields read-only. A rebuilt minter refusesNonebefore flush;test_self_service_keys_are_never_unscopedguards the developer route and nothing guards this one (Tests). - The key of record is the hash:
key_hashissha256(raw_key)and the raw key exists server-side only inside the request that minted it (1565 to 1567, 1602). - The CLI accepts a callback only for the
stateit generated (auth.py31 to 35) and only on/callback(23 to 26); a request to the loopback server with any otherstateis 400 and leavesresultunset. - The credentials file is
0600and lives under the user's home; the key is never printed in full by the CLI (auth_cmd.py27 to 28 and 52). - The route never reads
AuthorizationorX-API-Key: a key cannot mint another key through this door, and an SDK bearer cannot either (1531). - The tenant on the key is a workspace the user owns, or nothing; the route never picks a membership workspace and never trusts a header for it (1571 to 1574).
Frontend
The frontend modules that render this layer, by id:
identity-auth/ui/api-keys-settings-and-developer-dashboard /settings/api-keys lists the key this route minted
("AlchemyCLI on CLI", scopes read and write) beside the
self-service ones and revokes it, when the key's
workspace_id is the SPA's active workspace; a key with
NULL workspace_id or bound to another owned workspace
is shown nowhere (Gotchas); the inventory row F3 says the page explains the CLI
callback; at fd8558f no string in frontend/src mentions
the CLI (grep for "cli-login", "AlchemyCLI",
"alchemy login": zero hits, 2026-09-12)
infra-deploy/ui/deploy-and-ops-cli-surfaces (planned) the terminal side: "Opening browser for login...",
"Waiting for authentication (timeout: 2 minutes)...",
"Logged in as <email>", "API key: prima_pk_xx..."
(cli/alchemy_cli/auth.py 96 to 104, auth_cmd.py 25 to 28)
identity-auth/ui/auth-screens not a renderer of this layer and not in frontend_modules:
the /login page the flow passes through, which reads
?redirect= and sends the browser back to this route
Minimum admin and debug surface: the row itself (SELECT id, name, key_prefix, permissions, workspace_id, created_at FROM api_keys WHERE description = 'Auto-created by AlchemyCLI login'), the boot-independent log line Created CLI API key <id> for user <id> (1599), and alchemy auth whoami, which prints the stored email, workspace id and key prefix without a network call (auth_cmd.py 38 to 54). alchemy auth status is the connectivity check.
Security & tenancy
Auth dependency: none on the route; it decodes the cookie itself with S5's decode_token_with_surface and answers 403 through _require_labs_grant. Workspace derivation: the owner query above, never X-Workspace-ID. Fail direction: closed for the grant (no row, no key); closed for a bad or missing cookie (redirect to login, nothing minted); open in one respect, the exception handler puts str(e) in the callback URL (1614), which is a Python exception message on a loopback URL in the developer's own browser history.
Attacker-controlled inputs: callback_port (an int, any value; the redirect target is always http://localhost:<port>/callback, so the bound is a listener on the victim's own machine, and the CLI's state check does nothing for it: the check runs inside the CLI's own listener (auth.py 31 to 35), and a listener the attacker controls on that port receives the raw key in the redirect URL and checks nothing), state (any string; echoed raw into the login redirect, 1546 to 1547, and percent-encoded by urlencode into the callback redirect only, 1602 to 1609), the cookie (S5 bounds it). The mint is a cookie-authenticated GET with no dependency, no CSRF, Origin or Sec-Fetch-Site check and no rate limit (1510 to 1516, 1531), and a SameSite=Lax cookie travels on a top-level cross-site GET navigation, so a signed-in Labs user who follows a crafted cli-login?callback_port=<P>&state=<x> link mints a read/write key that is delivered to localhost:<P> and to the browser history, with nothing on the CLI side involved (Gotchas). The redirect query the SPA receives is this route's absolute URL, and LoginAlchemy.tsx follows any absolute redirect for an authenticated visitor (94 to 100); this flow is the reason that page cannot restrict redirect to relative paths, and the fix that keeps both working is an allowlist of one origin, API_BASE_URL (Gotchas).
Secrets at rest: key_hash only, server-side; the plaintext in ~/.alchemy/credentials at 0600 client-side; the plaintext also transits as a query string, ?api_key=, from the API origin to localhost, so it lands in the browser history and, on the API side, in any access log that records full URLs of redirect targets (the route itself logs the key id, not the key). Rate limits: none on this route (auth_rate_limit is not declared on it, auth_router.py line 16 and the eight dependencies=[Depends(auth_rate_limit)] declarations from 234 to 1007; S5 lists them).
Tenancy of the credential: user-bound and, when the user owns a workspace, workspace-bound; the CLI sends X-Workspace-ID from the callback value, and S6 re-validates that header against the key owner's memberships on every call. A user who owns two workspaces gets whichever row Postgres returns first for LIMIT 1 without ORDER BY.
Tests
Backend (alchemy_labs/backend/tests/, grep 2026-09-12 at fd8558f): no test file references cli-login or cli_login. The route is exercised by nothing hermetic and nothing live. What covers its neighbours: test_surface_scoped_access.py::test_self_service_keys_are_never_unscoped (line 440) pins the "always write scopes" rule on POST /api/developer/keys, and test_login_requires_an_explicit_labs_grant (544) pins _require_labs_grant on /login; both run against the :5434 Postgres (S9). A rebuilt route gets the three tests it lacks: a request with a labs session cookie and callback_port=1&state=abc answers 307 whose Location starts with http://localhost:1/callback? and whose api_key query value hashes to a new api_keys.key_hash whose permissions->'scopes' is exactly ["read", "write"], asserted on the column and not on the constructor argument; the same request with a session cookie for an account without a labs grant (the api_only_account fixture, test_surface_scoped_access.py 102 to 107, cookie from create_session(user_id=..., surface="prima_web")) answers 403 and inserts nothing, and the route is surface-blind, so it is the grant and not the surface that the test pins; and a unit test that the minting helper refuses permissions=None before flush, since the ORM stores that as JSON null and the key is then unlimited (Data model).
CLI: no tests under cli/ at fd8558f. The pure helpers a new CLI tests with pytest and no network: _CallbackHandler.do_GET against a fake socket for the five branches (wrong path, wrong state, error, missing credentials, success), save_credentials writing 0600, get_api_key preferring the environment.
Known-answer vector for the shape of the key, so a rebuilt minter can be checked without a database: secrets.token_urlsafe(32) is 43 characters, so a raw key is prima_pk_ plus 43 characters, 52 in all, matching Prima's _KEY_SHAPE (prima/auth/keys.py line 89 at prima cd2c248, feature/per-model-pricing, ^[A-Za-z0-9_\-]{16,256}$); key_prefix is 15 characters, prima_pk_ plus three plus ...; key_hash is 64 lowercase hex characters (computed 2026-09-12).
Playwright gate: none; the flow needs a loopback listener the browser test cannot own.
Deploy & ops
Config keys per app heredoc (S13): API_BASE_URL and FRONTEND_URL on the backend, both already required by other routes; nothing new. On the developer's machine, ALCHEMY_API_URL when the target is not the production API. Migrations: none owned here; api_keys is the baseline table (S8). Scheduler jobs: none. Argus checks: none specific; the route is a redirect flow and answers 307 to the login page for an anonymous GET, which is a cheap liveness probe if one is wanted.
Rollback: the route is stateless apart from the row it inserts; reverting it leaves minted keys working. A CLI key is revoked at /settings/api-keys (DELETE /api/developer/keys/{id}, B10) when the SPA's active workspace is the key's, otherwise by SQL or an admin route (Gotchas), and never by the CLI: alchemy auth logout deletes the local file and nothing server-side (auth_cmd.py 31 to 35).
Gotchas
- 2026-09-12 (measured at fd8558f): the "2 minutes" the CLI prints is not a timeout.
server.timeout = 120bounds onehandle_request()call, andbrowser_loginloopswhile _CallbackHandler.result is None: server.handle_request()(cli/alchemy_cli/auth.py103 to 109), so a timeout returns to the loop and the process waits until a callback arrives or the user interrupts it. A rebuilt CLI keeps a deadline outside the loop. - 2026-09-12 (measured at fd8558f): every
alchemy auth loginmints a new row and none is ever revoked by the CLI;logoutunlinks the credentials file (config.py34 to 36). A developer who logs in weekly accumulates keys namedAlchemyCLI on CLIthat all work. Revocation is manual, on the settings card when the key's workspace is the active one, else by SQL or an admin route (next Gotcha). - 2026-09-12 (measured at fd8558f, D8, fix needed): a CLI key can be invisible to the only page that revokes it.
GET /api/developer/keys(developer_router.py213 to 236) andDELETE /api/developer/keys/{id}(258 to 293, the comment says it is filtered the same wayGETis) addAPIKey.workspace_id == workspace_idwhenever a workspace context is present, and both SPA callers sendX-Workspace-IDwhenevercurrentWorkspaceIdis set (frontend/src/services/api.ts38 to 41, behindApiKeysCard.tsx83;DeveloperDashboard.tsx47 to 48 for its ownfetchcalls at 55, 98 and 121). A key this route minted withworkspace_idNULL (the member-only user of theLIMIT 1Gotcha below) or bound to an owned workspace that is not the active one matches neither query: the card does not list it, the delete answers 404API key not found(292 to 293), and nothing on the CLI side revokes (auth_cmd.py31 to 35). Until then it is revoked by SQL (UPDATE api_keys SET is_active = false WHERE id = ...) or an admin route. A rebuilt route either refuses to mint without an owned workspace, or the list and delete routes include the caller's NULL-workspace keys in every context. - 2026-09-12 (measured at fd8558f, D8, fix needed): the mint is surface-blind. Step 2 decodes the cookie with
decode_token_with_surfaceand discards the surface (_surface, 1535), and_require_labs_grant(140 to 152) readsuser_surface_grantsonly (services/surface_service.py128 to 132), so alabs-granted user who presents aprima_websession cookie mints. The Purpose says the trade is a Labs session for a Labs credential; at fd8558f it is any session of a Labs-granted user. A rebuilt route also refuses when_surfaceis notLABS. Not changed in this wave. - 2026-09-12 (measured at fd8558f, D8, fix needed): the
/loginhop losesstate.auth_router.py1546 to 1547 buildlogin_url = f"{frontend_url}/login?redirect={cli_login_url}"with the nested URL raw, and Starlette'sRedirectResponsequotes with&,?and=in its safe set, so the browser receives/login?redirect=http://localhost:8000/api/auth/cli-login?callback_port=<port>&state=<state>andsearchParams.get('redirect')(LoginAlchemy.tsx16) returnshttp://localhost:8000/api/auth/cli-login?callback_port=<port>, withstateparsed as a separate parameter of/login(parse_qsof that query printsredirect=['...cli-login?callback_port=1'],state=['abc'], reproduced 2026-09-12). After sign-in the SPA navigates to that value, the route runs withstate="",urlencodeemitsstate=, the CLI'sparse_qsdrops the blank value (auth.py28 to 29) andNone != expected_stateanswers 400Invalid state parameter. Please try again.(31 to 35);resultstaysNone, the loop at 106 to 107 waits without end, and oneAlchemyCLI on CLIrow is already inserted. The fix a rebuilt route needs:login_url = f"{frontend_url}/login?{urlencode({'redirect': cli_login_url})}"(orquote(cli_login_url, safe="")), so the SPA gets one encoded absolute value and follows it unchanged. Until then the flow completes only when the browser already holds the API-originaccess_tokencookie, so no hop happens. Not changed in this wave. - 2026-09-12 (measured at fd8558f, D8, fix needed): the sign-in the SPA performs stores no cookie the route can read. The route reads
access_tokenon the API origin only (1531). The SPA and the API are separate origins (productionlabs.alchemy.zuerichagainstopus.alchemy.zuerich,scripts/render-build-frontend.sh49; stagingstaging-labsagainststaging-opus,deploy/infomaniak/Caddyfile.staging51 and 156; locally:3000againstVITE_API_URLdefaulting tohttp://localhost:8000,AuthContext.tsx64), the password login isaxios.postwithwithCredentialsset only underUSE_COOKIE_AUTH(AuthContext.tsx21 to 23, 182;config/authConfig.ts39), the passkey and magic-link completions arefetchwithoutcredentials: 'include'(LoginAlchemy.tsx106 to 114), andscripts/build-frontend-staging.sh90 to 94 dies whenVITE_USE_COOKIE_AUTHis on, saying the password login sends nowithCredentials. A non-credentialed cross-origin XHR orfetchdiscards the response'sSet-Cookie, so after any of those sign-ins the route finds no cookie and bounces to/login, and a visitor the SPA already knows is sent straight back (LoginAlchemy.tsx94 to 100):/login,cli-login,/login, without end. The one sign-in that does store the cookie is the Infomaniak OIDC callback, a top-level redirect served by the API host (auth_infomaniak_router.py128 to 130), and it does not carry the CLI'sredirect. The fix that keeps the route cookie-only: the SPA's session-issuing requests sendwithCredentials: trueorcredentials: 'include'(CORS already answersallow_credentials=True,app_fastapi.py449), or the API is served under the SPA's origin. Not changed in this wave. - 2026-09-12 (measured at fd8558f, D8, fix needed): the mint has no request-origin check.
cli_logindeclares no dependency, reads the cookie only and takescallback_portfrom the query (1510 to 1516, 1531), and the cookie isSameSite=Lax, which a top-level cross-siteGETnavigation carries. A signed-in Labs user who clickshttps://<api>/api/auth/cli-login?callback_port=<P>&state=<x>on any page mints a read/write key and hands it to whatever listens onlocalhost:<P>; the CLI'sstatecheck (auth.py31 to 35) protects the CLI from a foreign callback, not the key from a foreign listener. A rebuilt route refuses the mint unlessSec-Fetch-Siteisnone(a typed or app-opened URL, which is whatwebbrowser.openproduces) orsame-site, or binds the mint to a one-time nonce the CLI registers first, and declaresauth_rate_limit. Not changed in this wave. - 2026-09-12 (measured at fd8558f): a magic-link sign-in does not complete the flow.
LoginAlchemy.tsxline 150 forwards only a relativeredirectasnexton the magic-link request, and the CLI's redirect is absolute, so the mailbox path lands on/dashboard; the developer opens the printed URL again, now with a cookie, and the mint happens. Password and passkey logins follow the absolute redirect (178 to 182, 82, 127). - 2026-09-12 (measured at fd8558f, D8, fix needed):
LoginAlchemy.tsxlines 94 to 100 send an already-authenticated visitor to any absoluteredirectit is given. That is what this flow relies on, and it is also an open redirect on the login page. The fix that keeps the CLI working: acceptredirectwhen it is relative or when its origin equalsAPI_BASE_URL, else ignore it. Not changed in this wave. - 2026-09-12 (measured at fd8558f, D8, fix needed): the exception branch (
auth_router.py1611 to 1616) copiesstr(e)into the callback URL, so a database error's text reaches the developer's browser history and terminal. A rebuilt route sends a fixed token (error=mint_failed) and keeps the text in the log. - 2026-09-12 (measured at fd8558f): the workspace is
LIMIT 1withoutORDER BYover the user's owned workspaces (1571 to 1574). For a multi-workspace owner the CLI's storedworkspace_idis arbitrary but stable in practice;ALCHEMY_WORKSPACE_IDoverrides it (config.py53 to 58). The route never considers membership workspaces, so a member-only user getsworkspace_id=""; S6 then resolves the workspace only when the user belongs to exactly one, and a member of several gets S6's 400 on every workspace-scoped CLI call until the variable is set. - 2026-09-12 (measured at fd8558f): two cookie writers, and the one this route usually depends on is not the surface-aware one. The SPA keeps tokens in
localStorage(S2); the cookie is set alongside byauth_router.py_set_auth_cookies(59 to 75) for/register(409),/login(622),/refresh(656),/verify-magic-link(1240, the only magic-link redemption), the Infomaniak OIDC callback (auth_infomaniak_router.py128 to 130) and the development-only/dev-login(1711, registered underif settings.ENVIRONMENT == "development"at 1684 to 1685): alwaysSameSite=Lax,Secureonly whenENVIRONMENT == "production"(line 61), path/api, noDomain, no site check.core/session_cookies.set_auth_cookies(64 to 113), the one with the same-site and cross-site branches (is_same_site49 to 61;SameSite=Laxsame-site,SameSite=None; Securecross-site, 79 to 87), is called bywebauthn_router.pyline 145 only, so it describes passkey sessions and nothing else. Where the cookie is lost, per path: on the password and magic-link paths, and on the passkey completion through the other writer, the request is a cross-origin, same-site XHR orfetchsent without credentials, and a non-credentialed request discardsSet-CookiewhateverSameSiteorENVIRONMENTsays (previous Gotcha); that flag is the sole cause. The pairs are same-site:labs.alchemy.zuerichandopus.alchemy.zuerichshare one registrable domain (scripts/render-build-frontend.sh49,core/surfaces.py127),staging-labsandstaging-opuslikewise (deploy/infomaniak/Caddyfile.staging51, 156), andlocalhost:3000againstlocalhost:8000(AuthContext.tsx64) differ by port only, which the browser's site notion ignores; a credentialed request therefore does store theLaxcookie, which is why the fix in the previous Gotcha works at all. OIDC is not on the lost list: it is a top-level redirect served by the API host and stores the cookie (previous Gotcha). The passkey path has a second, separate effect:localhostis not one of the Labs surface's hosts (core/surfaces.py127) andcookie_domainis that host (131), sois_same_siteis false on localhost and the cross-site branch setsSecureover plainhttp, which browsers store only becausehttp://localhostcounts as a secure context.is_same_siteis Labs' host-based notion (a request host that maps to the surface or ends in itscookie_domain,session_cookies.py49 to 61), not the browser's site notion, and the two must not be conflated. Earlier readings of this recipe were wrong twice: version 1 attributed the branches to the password path; version 2 called the pairs cross-site and listed OIDC among the lost paths. - 2026-09-12 (measured at fd8558f): the name hostname is the literal
"CLI"(hostname = "CLI", line 1561,key_name1562), so every key isAlchemyCLI on CLI; the client's hostname never reaches the server. A rebuilt CLI that wants a useful name sends it as a query parameter and the route bounds it. - 2026-09-12 (measured at fd8558f): before fb7159f (2026-08-02) this route minted with
permissionsunset and without a grant check, which is the "empty permissions means unlimited" hazard the inventory row B9 records andprima/seams/identity.pylines 158 to 162 (prima cd2c248,feature/per-model-pricing) still describes; both are fixed on the shipping line, and the comments at 1550 to 1556 and 1577 to 1583 are the record.
Reference implementation
Paths are relative to the alchemy_labs repo root; commit fd8558f on d1a-token-source (backend identical to feat/service-desk), read 2026-09-12.
backend/api/routers/auth_router.py:GATEWAY_KEY_PREFIXimport (5),_require_labs_grant(140 to 152),_set_auth_cookies(59 to 75, the writer of the cookie the route reads on every path but passkey),cli_login(1510 to 1616: cookie read 1531 to 1541, unauthenticated redirect 1543 to 1548 with the raw nested URL at 1546 to 1547, grant 1557, mint 1560 to 1597 with the ADR comment block 1577 to 1582, callback redirect 1602 to 1609, error branch 1611 to 1616 withstr(e)at 1614).backend/models/api_key_model.py:APIKey(14 to 83).backend/api/dependencies.py:_authenticate_via_api_key(53 to 157), the consumer of the key; the opt-in scope enforcement block is 106 to 132.backend/services/primaapi/identity.py:GATEWAY_KEY_PREFIX(59). This is the legacy in-Labs gateway copy (D3); the PrimaAPI reference for the key shape is the standaloneprimarepo,prima/auth/keys.pyline 89 at cd2c248 (feature/per-model-pricing), which Tests cites.backend/core/config.py:API_BASE_URL(42),FRONTEND_URL(43).backend/core/session_cookies.py:is_same_site(49 to 61),set_auth_cookies(64 to 113, branches 79 to 87); passkey sessions only, called fromapi/routers/webauthn_router.pyline 145.backend/api/routers/auth_infomaniak_router.py: the OIDC callback that sets the cookie from a top-level redirect (128 to 130), the one sign-in after which the route finds a cookie.backend/app_fastapi.py:allow_credentials=Trueon the SPA CORS policy (449).cli/alchemy_cli/auth.py:_CallbackHandler(15 to 76),_find_free_port(79 to 82),browser_login(85 to 116),manual_login(119 to 130).cli/alchemy_cli/config.py: paths andDEFAULT_API_URL(7 to 11),save_credentials(18 to 22),clear_credentials(34 to 36),get_api_key(39 to 45),get_api_url(48 to 50),get_workspace_id(53 to 58).cli/alchemy_cli/commands/auth_cmd.py:login(14 to 28),logout(31 to 35),whoami(38 to 54, key preview at 52).cli/alchemy_cli/client.py: theX-API-KeyandX-Workspace-IDheaders (38 to 44).frontend/src/pages/LoginAlchemy.tsx:redirectread (16), the authenticated-visitor redirect (94 to 100,window.location.href), passkey (82, 127,window.location.replace; the non-credentialedfetchat 106 to 114), magic link relative-only (150), password (178 to 182).frontend/src/contexts/AuthContext.tsx:withCredentialsonly underUSE_COOKIE_AUTH(21 to 23),API_URLdefault (64), the passwordaxios.post(182);frontend/src/config/authConfig.ts:USE_COOKIE_AUTH(39).scripts/build-frontend-staging.sh: the guard that refusesVITE_USE_COOKIE_AUTH(90 to 94);scripts/render-build-frontend.sh: the production API origin (49);deploy/infomaniak/Caddyfile.staging: the two staging hosts (51, 156).- Wiki
philosophers_stone(6322fa3):operations/prima-modules-inventory.mdrow B9 and F3;platform/architecture.md"Authentication & Authorization".
Deviations of the reference from this recipe, all dated 2026-09-12: the /login hop loses state (raw nested URL at 1546 to 1547), so the browser flow ends in the CLI's 400 whenever a hop happens; the SPA's sign-in requests are not credentialed against the API origin, so a sign-in through the hop stores no cookie the route can read and the flow loops /login to cli-login; the mint has no request-origin check and no rate limit; no test on either side; the infinite wait behind a "2 minutes" message; the exception text in the callback URL; the open redirect on the login page; the literal hostname; LIMIT 1 without an order; keys never revoked by the CLI, and a NULL-workspace or other-workspace key invisible to the settings card that revokes; the surface-blind mint. The first two together mean the reference completes the flow only for a browser that already holds the API-origin cookie (in practice, after an Infomaniak OIDC sign-in, or a cookie placed by this kit's tooling/verify/labs-local.sh, lines 189 to 190, a prima-modules file and not one in alchemy_labs).
Acceptance checks
Run from alchemy_labs/backend at fd8558f with the app on http://127.0.0.1:8000 and the SPA on http://127.0.0.1:3000, or against a new service built to this recipe. Expected observations are exact.
- With the default
FRONTEND_URLandAPI_BASE_URL:curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'http://127.0.0.1:8000/api/auth/cli-login?callback_port=1&state=abc'prints, on a service built to this recipe,307 http://localhost:3000/login?redirect=http%3A%2F%2Flocalhost%3A8000%2Fapi%2Fauth%2Fcli-login%3Fcallback_port%3D1%26state%3Dabc(theurlencodeoutput, computed 2026-09-12); with other values the two origins are those values, encoded the same way. At fd8558f it prints307 http://localhost:3000/login?redirect=http://localhost:8000/api/auth/cli-login?callback_port=1&state=abc, the raw form that losesstate(Gotchas);python3 -c "from urllib.parse import parse_qs; print(parse_qs('redirect=http://localhost:8000/api/auth/cli-login?callback_port=1&state=abc'))"shows the split. - With
$Canaccess_tokencookie from alabssession:curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -b "access_token=$C" 'http://127.0.0.1:8000/api/auth/cli-login?callback_port=1&state=abc'prints307 http://localhost:1/callback?api_key=prima_pk_...&email=...&workspace_id=...&state=abc; theapi_keyvalue is 52 characters and starts withprima_pk_. psql -c "SELECT permissions, is_active, expires_at FROM api_keys ORDER BY created_at DESC LIMIT 1"after check 2 prints{"scopes": ["read", "write"]} | t |(no expiry).- With
$Panaccess_tokencookie for an account without alabsgrant (theapi_only_accountfixture oftest_surface_scoped_access.py102 to 107; the cookie fromcreate_session(user_id=<that account>, surface="prima_web")in a shell): the same request with-b "access_token=$P"prints403and theapi_keyscount is unchanged. The complementary observation, same shell: aprima_webcookie for a user who holds alabsgrant prints307 http://localhost:1/callback?...and the count grows by one; the mint is surface-blind (Gotchas), and the invariant checked here is the grant. - With
$Wtheworkspace_idfrom check 2's callback URL (list_form_templatesdeclaresworkspace_id: WorkspaceID,forms_router.py141 to 150, so without the header a user with several workspaces gets S6's 400):curl -s -H "X-API-Key: $K" -H "X-Workspace-ID: $W" http://127.0.0.1:8000/api/forms/templateswith the key from check 2 is 200;curl -s -X POST -H "X-API-Key: $K" -H "X-Workspace-ID: $W" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:8000/api/forms/templatesis not 403 with a scope message (it is the endpoint's own validation error);curl -s -H "X-API-Key: $K" http://127.0.0.1:8000/api/prima/v1/modelsis 403 naming theaiscope. That is the read-and-write key, enforced by S5's method matrix. cd cli && python -c "from alchemy_cli.auth import _find_free_port; p=_find_free_port(); print(0 < p < 65536)"printsTrue;python -c "import secrets; print(len(secrets.token_urlsafe(32)))"prints43.- End to end, by hand, on a service built to this recipe (encoded
redirect, credentialed sign-in):ALCHEMY_API_URL=http://127.0.0.1:8000 alchemy auth loginprintsOpening browser for login..., the browser lands on/login, after a password sign-in the tab showsLogin successful! You can close this tab., the terminal printsLogged in as <email>andAPI key: prima_pk_xxx...,cat ~/.alchemy/credentialsshows the three fields andstat -f %Lp ~/.alchemy/credentialsprints600. At fd8558f, from a browser with no cookie for127.0.0.1:8000, the same command lands on/loginand after a password sign-in the browser is sent back tocli-loginwithout a cookie and back to/login, in a loop, while the terminal prints nothing afterWaiting for authentication (timeout: 2 minutes).... A build that fixes only the credentialed sign-in (or serves the API under the SPA's origin) and keeps the raw redirect of 1546 to 1547 shows the other failure: the tab showsInvalid state parameter. Please try again., the terminal keeps waiting, andSELECT count(*) FROM api_keys WHERE name = 'AlchemyCLI on CLI'has grown by one. Only a browser that already holds a live API-origin cookie, so that no/loginhop happens, reaches the success observation at fd8558f (verified by reading the SPA, the build script and the deploy layout, plus theparse_qsreproduction; not run in a browser, 2026-09-12). grep -rn "cli-login\|cli_login" tests/ | wc -lprints0at fd8558f; on a new service it prints at least2(the two route tests named under Tests; theNoneunit test need not name the route).
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.