Prima Get an API key

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
  • alchemy_labs/backend/api/routers/auth_router.py
  • alchemy_labs/backend/models/api_key_model.py
  • alchemy_labs/backend/api/dependencies.py
  • alchemy_labs/cli/alchemy_cli/auth.py
  • alchemy_labs/cli/alchemy_cli/config.py
  • alchemy_labs/cli/alchemy_cli/commands/auth_cmd.py
  • alchemy_labs/frontend/src/pages/LoginAlchemy.tsx
  • alchemy_labs/frontend/src/contexts/AuthContext.tsx
  • alchemy_labs/backend/core/session_cookies.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Also measured on
  • prima feature/per-model-pricing cd2c248 2026-09-12
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.

NameDefaultBehaviour
API_BASE_URLhttp://localhost:8000 (line 42)the origin the route embeds in the login redirect so the SPA can send the browser back here
FRONTEND_URLhttp://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)
ENVIRONMENTdevelopmentdecides 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.

RouteAuth depTenancyCodesNotes
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-Keythe first workspace the user owns, SELECT id FROM workspaces WHERE owner_id = :uid LIMIT 1 (lines 1571 to 1574); X-Workspace-ID is not read307 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:

  1. CLI (browser_login, auth.py 85 to 116): bind a free port on 127.0.0.1 (_find_free_port, 79 to 82), generate state = secrets.token_urlsafe(32), start HTTPServer with the handler's expected_state set, print the login URL and call webbrowser.open on {ALCHEMY_API_URL}/api/auth/cli-login?callback_port={port}&state={state}.
  2. Route: read the access_token cookie. If present, auth_service.decode_token_with_surface(token, token_type="access") and get_user_by_id(sub); any exception is swallowed and leaves user = None (1533 to 1541). The surface the token was minted for is read and discarded.
  3. No user: redirect to the SPA's /login with redirect= set to this route's own URL, query included (1543 to 1548). The SPA (LoginAlchemy.tsx) reads redirect from the query (line 16); after a password login it sets window.location.href to it (178 to 182), after a passkey login window.location.replace (82, 127); an already-signed-in visitor is sent there at once (94 to 100). The magic-link path keeps only relative redirect values (150), so a sign-in by mail lands on /dashboard and 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, so searchParams.get('redirect') returns .../cli-login?callback_port=<port> and state is 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 no access_token cookie 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, with state="", which the CLI refuses in step 9.
  4. User found: _require_labs_grant(user) (1557). It reads the user's grant rows (get_user_surfaces_standalone, services/surface_service.py 128 to 132, over user_surface_grants only) and not the token: an account without a labs grant (an api_only account, or one whose grant was revoked) stops here with 403 and never mints, while a labs-granted user who presents a prima_web session 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.
  5. Mint: raw_key = "prima_pk_" + secrets.token_urlsafe(32) (GATEWAY_KEY_PREFIX imported from services/primaapi/identity.py line 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).
  6. Workspace: the first row of workspaces WHERE owner_id = :uid LIMIT 1, with no ORDER BY (1571 to 1574); workspace_id is that id or None, and the string sent to the CLI is that id or "".
  7. Insert the APIKey row with permissions={"scopes": ["read", "write"]} and is_active=True, db.commit() (1585 to 1597). Nothing is enqueued to Prima: the key has no ai scope, so it is not a gateway key (B12's is_gateway_key).
  8. Redirect to http://localhost:{callback_port}/callback with api_key, email, workspace_id and state URL-encoded (1602 to 1609).
  9. CLI: the handler checks state (a mismatch, including the empty state= a lost value produces, is 400 Invalid state parameter. Please try again. and leaves result unset), reads the three values, answers the browser with a static "Login successful" HTML page, and browser_login returns the dict; login writes ~/.alchemy/credentials with mode 0600 and prints the email and the first twelve characters of the key (auth_cmd.py 25 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 /login hop happened.
  10. 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 into click.ClickException("Login failed: ...") (auth.py 113 to 114).

Invariants

  • No mint without the Labs question: _require_labs_grant runs before the APIKey constructor on every path that reaches it (1557 before 1585), and it reads user_surface_grants live through get_user_surfaces_standalone, the same reader /login uses (S5).
  • The key is never unscoped: permissions is written as a literal {"scopes": ["read", "write"]} (1593). A rebuilt route that writes {}, a dict without a non-empty scopes list, or an explicit None produces an unlimited key (S5 Invariants, "an empty scope list means unlimited"); the None case is stored as JSON null, passes NOT NULL and fails nothing (Data model), so the only safe writes are a literal non-empty scopes list, or no argument at all, which yields read-only. A rebuilt minter refuses None before flush; test_self_service_keys_are_never_unscoped guards the developer route and nothing guards this one (Tests).
  • The key of record is the hash: key_hash is sha256(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 state it generated (auth.py 31 to 35) and only on /callback (23 to 26); a request to the loopback server with any other state is 400 and leaves result unset.
  • The credentials file is 0600 and lives under the user's home; the key is never printed in full by the CLI (auth_cmd.py 27 to 28 and 52).
  • The route never reads Authorization or X-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 = 120 bounds one handle_request() call, and browser_login loops while _CallbackHandler.result is None: server.handle_request() (cli/alchemy_cli/auth.py 103 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 login mints a new row and none is ever revoked by the CLI; logout unlinks the credentials file (config.py 34 to 36). A developer who logs in weekly accumulates keys named AlchemyCLI on CLI that 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.py 213 to 236) and DELETE /api/developer/keys/{id} (258 to 293, the comment says it is filtered the same way GET is) add APIKey.workspace_id == workspace_id whenever a workspace context is present, and both SPA callers send X-Workspace-ID whenever currentWorkspaceId is set (frontend/src/services/api.ts 38 to 41, behind ApiKeysCard.tsx 83; DeveloperDashboard.tsx 47 to 48 for its own fetch calls at 55, 98 and 121). A key this route minted with workspace_id NULL (the member-only user of the LIMIT 1 Gotcha 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 404 API key not found (292 to 293), and nothing on the CLI side revokes (auth_cmd.py 31 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_surface and discards the surface (_surface, 1535), and _require_labs_grant (140 to 152) reads user_surface_grants only (services/surface_service.py 128 to 132), so a labs-granted user who presents a prima_web session 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 _surface is not LABS. Not changed in this wave.
  • 2026-09-12 (measured at fd8558f, D8, fix needed): the /login hop loses state. auth_router.py 1546 to 1547 build login_url = f"{frontend_url}/login?redirect={cli_login_url}" with the nested URL raw, and Starlette's RedirectResponse quotes with &, ? and = in its safe set, so the browser receives /login?redirect=http://localhost:8000/api/auth/cli-login?callback_port=<port>&state=<state> and searchParams.get('redirect') (LoginAlchemy.tsx 16) returns http://localhost:8000/api/auth/cli-login?callback_port=<port>, with state parsed as a separate parameter of /login (parse_qs of that query prints redirect=['...cli-login?callback_port=1'], state=['abc'], reproduced 2026-09-12). After sign-in the SPA navigates to that value, the route runs with state="", urlencode emits state=, the CLI's parse_qs drops the blank value (auth.py 28 to 29) and None != expected_state answers 400 Invalid state parameter. Please try again. (31 to 35); result stays None, the loop at 106 to 107 waits without end, and one AlchemyCLI on CLI row is already inserted. The fix a rebuilt route needs: login_url = f"{frontend_url}/login?{urlencode({'redirect': cli_login_url})}" (or quote(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-origin access_token cookie, 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_token on the API origin only (1531). The SPA and the API are separate origins (production labs.alchemy.zuerich against opus.alchemy.zuerich, scripts/render-build-frontend.sh 49; staging staging-labs against staging-opus, deploy/infomaniak/Caddyfile.staging 51 and 156; locally :3000 against VITE_API_URL defaulting to http://localhost:8000, AuthContext.tsx 64), the password login is axios.post with withCredentials set only under USE_COOKIE_AUTH (AuthContext.tsx 21 to 23, 182; config/authConfig.ts 39), the passkey and magic-link completions are fetch without credentials: 'include' (LoginAlchemy.tsx 106 to 114), and scripts/build-frontend-staging.sh 90 to 94 dies when VITE_USE_COOKIE_AUTH is on, saying the password login sends no withCredentials. A non-credentialed cross-origin XHR or fetch discards the response's Set-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.tsx 94 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.py 128 to 130), and it does not carry the CLI's redirect. The fix that keeps the route cookie-only: the SPA's session-issuing requests send withCredentials: true or credentials: 'include' (CORS already answers allow_credentials=True, app_fastapi.py 449), 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_login declares no dependency, reads the cookie only and takes callback_port from the query (1510 to 1516, 1531), and the cookie is SameSite=Lax, which a top-level cross-site GET navigation carries. A signed-in Labs user who clicks https://<api>/api/auth/cli-login?callback_port=<P>&state=<x> on any page mints a read/write key and hands it to whatever listens on localhost:<P>; the CLI's state check (auth.py 31 to 35) protects the CLI from a foreign callback, not the key from a foreign listener. A rebuilt route refuses the mint unless Sec-Fetch-Site is none (a typed or app-opened URL, which is what webbrowser.open produces) or same-site, or binds the mint to a one-time nonce the CLI registers first, and declares auth_rate_limit. Not changed in this wave.
  • 2026-09-12 (measured at fd8558f): a magic-link sign-in does not complete the flow. LoginAlchemy.tsx line 150 forwards only a relative redirect as next on 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.tsx lines 94 to 100 send an already-authenticated visitor to any absolute redirect it 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: accept redirect when it is relative or when its origin equals API_BASE_URL, else ignore it. Not changed in this wave.
  • 2026-09-12 (measured at fd8558f, D8, fix needed): the exception branch (auth_router.py 1611 to 1616) copies str(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 1 without ORDER BY over the user's owned workspaces (1571 to 1574). For a multi-workspace owner the CLI's stored workspace_id is arbitrary but stable in practice; ALCHEMY_WORKSPACE_ID overrides it (config.py 53 to 58). The route never considers membership workspaces, so a member-only user gets workspace_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 by auth_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.py 128 to 130) and the development-only /dev-login (1711, registered under if settings.ENVIRONMENT == "development" at 1684 to 1685): always SameSite=Lax, Secure only when ENVIRONMENT == "production" (line 61), path /api, no Domain, no site check. core/session_cookies.set_auth_cookies (64 to 113), the one with the same-site and cross-site branches (is_same_site 49 to 61; SameSite=Lax same-site, SameSite=None; Secure cross-site, 79 to 87), is called by webauthn_router.py line 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 or fetch sent without credentials, and a non-credentialed request discards Set-Cookie whatever SameSite or ENVIRONMENT says (previous Gotcha); that flag is the sole cause. The pairs are same-site: labs.alchemy.zuerich and opus.alchemy.zuerich share one registrable domain (scripts/render-build-frontend.sh 49, core/surfaces.py 127), staging-labs and staging-opus likewise (deploy/infomaniak/Caddyfile.staging 51, 156), and localhost:3000 against localhost:8000 (AuthContext.tsx 64) differ by port only, which the browser's site notion ignores; a credentialed request therefore does store the Lax cookie, 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: localhost is not one of the Labs surface's hosts (core/surfaces.py 127) and cookie_domain is that host (131), so is_same_site is false on localhost and the cross-site branch sets Secure over plain http, which browsers store only because http://localhost counts as a secure context. is_same_site is Labs' host-based notion (a request host that maps to the surface or ends in its cookie_domain, session_cookies.py 49 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_name 1562), so every key is AlchemyCLI 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 permissions unset and without a grant check, which is the "empty permissions means unlimited" hazard the inventory row B9 records and prima/seams/identity.py lines 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_PREFIX import (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 with str(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 standalone prima repo, prima/auth/keys.py line 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 from api/routers/webauthn_router.py line 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=True on 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 and DEFAULT_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: the X-API-Key and X-Workspace-ID headers (38 to 44).
  • frontend/src/pages/LoginAlchemy.tsx: redirect read (16), the authenticated-visitor redirect (94 to 100, window.location.href), passkey (82, 127, window.location.replace; the non-credentialed fetch at 106 to 114), magic link relative-only (150), password (178 to 182).
  • frontend/src/contexts/AuthContext.tsx: withCredentials only under USE_COOKIE_AUTH (21 to 23), API_URL default (64), the password axios.post (182); frontend/src/config/authConfig.ts: USE_COOKIE_AUTH (39).
  • scripts/build-frontend-staging.sh: the guard that refuses VITE_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.md row 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.

  1. With the default FRONTEND_URL and API_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 (the urlencode output, computed 2026-09-12); with other values the two origins are those values, encoded the same way. At fd8558f it prints 307 http://localhost:3000/login?redirect=http://localhost:8000/api/auth/cli-login?callback_port=1&state=abc, the raw form that loses state (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.
  2. With $C an access_token cookie from a labs session: 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' prints 307 http://localhost:1/callback?api_key=prima_pk_...&email=...&workspace_id=...&state=abc; the api_key value is 52 characters and starts with prima_pk_.
  3. 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).
  4. With $P an access_token cookie for an account without a labs grant (the api_only_account fixture of test_surface_scoped_access.py 102 to 107; the cookie from create_session(user_id=<that account>, surface="prima_web") in a shell): the same request with -b "access_token=$P" prints 403 and the api_keys count is unchanged. The complementary observation, same shell: a prima_web cookie for a user who holds a labs grant prints 307 http://localhost:1/callback?... and the count grows by one; the mint is surface-blind (Gotchas), and the invariant checked here is the grant.
  5. With $W the workspace_id from check 2's callback URL (list_form_templates declares workspace_id: WorkspaceID, forms_router.py 141 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/templates with 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/templates is 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/models is 403 naming the ai scope. That is the read-and-write key, enforced by S5's method matrix.
  6. cd cli && python -c "from alchemy_cli.auth import _find_free_port; p=_find_free_port(); print(0 < p < 65536)" prints True; python -c "import secrets; print(len(secrets.token_urlsafe(32)))" prints 43.
  7. 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 login prints Opening browser for login..., the browser lands on /login, after a password sign-in the tab shows Login successful! You can close this tab., the terminal prints Logged in as <email> and API key: prima_pk_xxx..., cat ~/.alchemy/credentials shows the three fields and stat -f %Lp ~/.alchemy/credentials prints 600. At fd8558f, from a browser with no cookie for 127.0.0.1:8000, the same command lands on /login and after a password sign-in the browser is sent back to cli-login without a cookie and back to /login, in a loop, while the terminal prints nothing after Waiting 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 shows Invalid state parameter. Please try again., the terminal keeps waiting, and SELECT 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 /login hop happens, reaches the success observation at fd8558f (verified by reading the SPA, the build script and the deploy layout, plus the parse_qs reproduction; not run in a browser, 2026-09-12).
  8. grep -rn "cli-login\|cli_login" tests/ | wc -l prints 0 at fd8558f; on a new service it prints at least 2 (the two route tests named under Tests; the None unit 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.