Skip to content

Pasqal Cloud auth: keychain silent re-auth (interim) + service account (target) — browser/device/refresh ruled out by evidence #194

Description

@kateebonner

Important

Problem

Pasqal Cloud credentials currently enter Amico through chat — the password rides the agent's command line (ps-visible, transcript-persisted), exactly the exposure ADR 0002 calls out. The Connections panel design needs its auth flow implemented, and the resolved design is browser sign-in: the password never enters Amico at all.

Approach

OAuth authorization-code + PKCE with a loopback callback (RFC 8252 — the gh auth login / gcloud pattern), implemented as an amico pasqal connect CLI verb so the fork server's Connections routes reuse it via the established spawn-a-CLI-child pattern, and headless/opencode serve surfaces get the identical flow for free. Sign-in happens on Pasqal's own Auth0 page (password manager / SSO / MFA native); Amico receives a single-use code on a 127.0.0.1 listener, exchanges it in-process for a revocable JWT access token, and persists token-only per ADR 0001.

Approaches Considered

  • Browser handoff via CLI verb + loopback callback — chosen: never touches the password, CLI parity, fits the fork's validator-child pattern.
  • In-panel username/password form, server-side password-grant mint (ADR 0001 original) — works today with zero Pasqal changes, but the password transits Amico memory; retained only as documented fallback if Pasqal declines the client toggle.
  • vscode:// URI-handler deep link back to the extension — rejected: extension-only surface, breaks opencode serve/CLI parity (ADR 0002's seam rationale).
  • Device-code grant — deferred, not rejected: better for SSH (no callback), but currently disabled on Pasqal's public client; becomes a follow-up slice if enabled.

Scope

In: PKCE + state generation; loopback callback listener; authorize-URL construction; code→token exchange; expiry derivation (from expires_in or JWT exp); atomic mode-at-birth 0600 write of the credential file; amico pasqal connect verb wiring with browser-open + --no-open; tests for all of the above against a stubbed token endpoint.
Out: Connections panel webview + fork-server route wiring — separate slice, consumes this verb.
Out: project picker from the account API — separate slice; until then connect takes an explicit --project so the credential-file contract is unchanged.
Out: refresh-token silent renewal — depends on what Pasqal's client config grants; file shape reserves the field.
Out: device-code variant — blocked on Pasqal client config (probed 2026-07-21: grant disabled).

Assumptions / Open Qs

  • Blocked externally for prod use, not for merge: Pasqal's public client (PeZvo7…, shipped in their SDK) today rejects loopback redirects (403 Callback URL mismatch) and has device-code disabled — probed live 2026-07-21. The ask: mark/register a native OAuth client with loopback redirect URIs and any-port matching (or enable device-code). All endpoints/client-id are env-overridable, so the flow is fully testable now and lights up when Pasqal flips the config.
  • Assumes the panel/fork-route slice will hold its POST open while this verb runs and read the verb's single-line JSON result (the existing validator child contract style).

Acceptance Criteria

  1. amico pasqal connect --project <id> opens the system browser to the authorize URL and, on callback with valid state, exchanges the code (with PKCE verifier) and writes the credential file readable by the existing launcher reader — round-trip proven in tests with a stubbed token endpoint and a synthetic callback request.
  2. The credential file is created atomically with mode 0600 at birth (tmp file born 0600, then rename) — asserted by test, including the tmp-file mode.
  3. The access token and refresh token never appear in argv, stdout JSON, error messages, or logs — the verb's JSON result carries identity/expiry/file-path only. (House AC3 discipline.)
  4. Callback hardening: listener binds 127.0.0.1 only; mismatched or missing state → 400 and the attempt keeps waiting; error= callback (e.g. access_denied) → clean non-zero exit with an actionable message; configurable timeout closes the listener and exits distinctly.
  5. Expiry is derived from expires_in when present, else the JWT exp claim (decoded unverified), else omitted — each path unit-tested; the stored expires_at satisfies assertPasqalFresh.
  6. --no-open prints the authorize URL instead of spawning a browser (SSH/headless escape hatch); a failed browser spawn degrades to the same and keeps waiting.
  7. Existing suite stays green; no changes to the launcher/reader contract or the connector scripts.

Key Decisions

  • Ephemeral port, bind-then-build: listener binds port 0 on 127.0.0.1 and the redirect URI is constructed from the actually-bound port — pre-registration squatting is impossible; a squatter racing a fixed port would surface as EADDRINUSE, never a silent hijack. This requires any-port loopback matching on Pasqal's client (part of the ask); a fixed-port fallback is a config value away if they refuse.
  • The verb, not the fork server, owns the listener — the fork server (fixed port 43117) may not exist in CLI/headless contexts, and its routes require the per-boot password that an Auth0 redirect cannot carry; the code is authenticated by state+PKCE instead.
  • Defaults for auth base / client id / audience are Pasqal's published prod values (public in their SDK source); env vars override all three (test stub + preprod).
  • Scope requested: openid profile email offline_access; identity (email) is read from the ID token for the panel's "Connected as" line and stored alongside the token; refresh_token, if granted, is stored for the future renewal slice. Extra keys are ignored by the existing reader — contract unchanged.
  • Failures are ConfigError/exit-64 class before spawn semantics apply; the callback-timeout and access-denied lanes get distinct messages pointing at the Connections panel.

Constraints & Invariants

  • Never bind beyond loopback; never accept a second in-flight attempt on one listener.
  • The password concept must not appear anywhere in this code path — this flow's entire premise is that Amico never sees it.
  • VS Code Remote translation (asExternalUri) may rewrite the callback port — another reason any-port matching is the ask; do not hardcode port assumptions anywhere but the bound-socket read.
Prior Art / Patterns
  • Launcher env-only secret carriage and token-free error discipline: the amico-pasqal launcher module and its tests.
  • Credential file location override idiom ($AMICO_PASQAL_FILE) and reader shape-checks: the launch module's reader (this slice writes what it reads).
  • Verb dispatch and {json, code} result shape: the existing amico pasqal verb.
  • At-rest discipline: ADR 0001 (token-only, atomic + 0600 at birth). Seam ownership: ADR 0002.

Source

Notes

Feasibility probes of 2026-07-21 against authenticate.pasqal.cloud: tenant supports authorization_code + device_code; the SDK's public client rejects both today (callback mismatch / grant disabled). Runtime-verified separately that token-provider injection works through the released SDK stack with no library patches.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions