Skip to content

phantom: Phase 6 PR-3 in-VM /auth/magic callback route + session cookie + cross-tenant defense#114

Merged
mcheemaa merged 3 commits intomainfrom
feat/2026-05-01-phase6-pr3-magic-link-callback
May 1, 2026
Merged

phantom: Phase 6 PR-3 in-VM /auth/magic callback route + session cookie + cross-tenant defense#114
mcheemaa merged 3 commits intomainfrom
feat/2026-05-01-phase6-pr3-magic-link-callback

Conversation

@mcheemaa
Copy link
Copy Markdown
Member

@mcheemaa mcheemaa commented May 1, 2026

Summary

Adds the per-tenant /auth/magic callback that redeems a one-time magic-link token after the dashboard 302s the user's browser to https://<slug>.phantom.ghostwright.dev/auth/magic?token=<43-char base64url>. The handler validates the token server-side via the metadata gateway, enforces a defense-in-depth chain, and mints a phantom_session cookie scoped to the per-tenant subdomain before redirecting to /chat.

This is the third of four cooperating PRs across phantom-control (mint + validate shim), phantomd (metadata-gateway proxy), phantom (this PR; in-VM callback), and the dashboard (mint route + agent-card click handler). The token IS the auth on this route, so /auth/magic is dispatched at the top level and bypasses the existing /ui/* cookie gate by design.

What changed

  • New src/ui/auth-magic.ts (~360 LOC). handleAuthMagic(req, deps) is the route entry; production wires the default fetch + clock, tests stub them via MagicAuthDeps.
  • Wired in src/core/server.ts at top-level /auth/magic (one new import + one match clause).
  • 45 new tests at src/ui/__tests__/auth-magic.test.ts covering happy path, every failure code, plaintext-leak guards, rate-limit semantics, cross-tenant + owner-email gates, open-redirect defense, refresh survival.
  • 2 route-wiring smoke tests at src/core/__tests__/server.test.ts to confirm the dispatch off /.
  • CLAUDE.md extended with a Phase 6 magic-link callback section documenting the route, env-var contract, cookie shape, defense-in-depth gates, and the cross-repo invariants this PR is paired against.

Cross-repo contract

The handler is a strict client of two upstream services. Every wire shape mirrors the upstream byte-for-byte; CI on either side surfaces drift.

  • Token shape: ^[A-Za-z0-9_-]{43}$ regex mirrors the upstream regexes by string identity. Mints emit 32 bytes from crypto/rand encoded as base64url-no-padding.
  • Validator request: POST http://169.254.169.254/v1/magic-link/validate with body {"token": "<43-char>"}. The metadata gateway proxies to phantom-control's bearer-auth shim; the bearer is operator-side only and never visible to this process.
  • Validator response on success (HTTP 200): {"agent_id": "...", "agent_slug": "...", "owner_email": "..."}. Mirrors phantom-control's magicLinkValidateResponse and phantomd's proxy magicLinkValidateResponse byte-for-byte.
  • Status mapping: 200 -> success, 404 -> expired/consumed/unknown, 400 -> bad shape, 403 -> cross-tenant, 429 -> rate limit, 5xx -> validator unavailable. Mirrors the gateway-level mapping verbatim.

Defense-in-depth gates (in order)

  1. Token shape regex (43-char base64url) before the rate-limit budget.
  2. Per-IP rate limit (10 / IP / minute, sliding window) before the validator hop. Successful validates do NOT bump the budget; the budget gates probing-by-strangers, not the legitimate flow.
  3. Tenant-config sanity (PHANTOM_TENANT_SLUG + PHANTOM_OWNER_EMAIL both required).
  4. Validator hop with 2-second timeout.
  5. Cross-tenant defense: agent_slug from validator must equal PHANTOM_TENANT_SLUG. The metadata-gateway proxy already enforces this; the in-VM handler duplicates the check so the trust anchor stays inside the VM even if a future code path bypasses the gateway.
  6. Owner-email binding: validator's owner_email must equal PHANTOM_OWNER_EMAIL (case-insensitive, trimmed). Sealed-tenant correctness invariant.
  7. Open-redirect defense on ?redirect=: must be a single-leading-slash path; rejects protocol-relative (//evil.com), absolute (https://evil.com), and backslash-trick (/\evil.com) variants.

Cookie shape

phantom_session=<32-byte base64url>;
  Domain=<slug>.phantom.ghostwright.dev;
  Path=/;
  HttpOnly; Secure; SameSite=Lax;
  Max-Age=604800

Per-slug Domain (NOT wildcard .phantom.ghostwright.dev): cross-tenant isolation is non-negotiable. SameSite=Lax (NOT Strict): preserves the bookmarked-direct-navigation UX. 7-day Max-Age matches the existing SESSION_TTL_MS in src/ui/session.ts. A second Set-Cookie clears any legacy Path=/ui cookie a browser may carry from before the cookie-flatten era.

Failure-path coverage

Every error path 302s to ${PHANTOM_DASHBOARD_URL}?magic_error=<code> so the dashboard's Sonner toast surfaces. Codes: invalid_token, rate_limited, expired, validator_unavailable, owner_mismatch, agent_mismatch, tenant_unconfigured. When PHANTOM_DASHBOARD_URL is unset (dev), the fallback is /ui/login?magic_error=....

The token plaintext is NEVER:

  • echoed in the response body on any path,
  • placed in the redirect Location on any path,
  • placed in the upstream URL path or query (POST body only),
  • passed to any console.log or structured-log call.

The catch on the validator fetch is deliberately silent on the caught error's message because some runtimes leak the request URL (which would carry the token) into thrown error strings.

Refresh / restart survival

  • Refresh /auth/magic?token=<x> after a successful first hit: validator returns 404 (single-use already consumed); user lands at ?magic_error=expired with no half-set cookie.
  • Refresh after a network failure (validator unreachable): no cookie set, no half-state on the client; the user sees ?magic_error=validator_unavailable and can retry from the dashboard.
  • Refresh after ?magic_error= lands on the dashboard: dashboard's existing toast deduplication (Phase 3 §9.7) handles this.
  • Process restart: the session-store map is in-memory; restart loses sessions and the user re-clicks an agent card. Phantom-control persists the magic-link table in Neon, so a restart between mint and consume is invisible to the validator hop.

Test plan

  • bun test -> 2199 pass / 10 skip / 1 todo / 0 fail (47 new tests added cleanly).
  • bun run lint -> 0 errors.
  • bun run typecheck -> 0 errors.

The new tests cover (see src/ui/__tests__/auth-magic.test.ts):

  • happy path (cookie attributes, validator wire body shape, redirect target, session is valid against isValidSession),
  • input shape gate (empty, too-short, off-by-one length, illegal chars, non-GET method),
  • validator failure paths (404 -> expired, 400 -> invalid_token, 403 -> agent_mismatch, 429 -> rate_limited, 500 -> validator_unavailable, 503 -> validator_unavailable, network error, AbortError timeout, malformed JSON shape, non-JSON body),
  • cross-tenant defense (validator returns mismatched agent_slug -> 403),
  • owner-email gate (case-insensitive),
  • tenant misconfiguration (missing slug or owner email -> handler refuses without invoking validator),
  • rate limit (11th attempt within window blocked, malformed-token requests do not consume budget, attempts outside window do not count, different IPs have independent budgets),
  • open-redirect defense (https://, //, backslash, javascript: all fall back to /chat),
  • plaintext-leak guards (token never in upstream URL, error redirect Location, response body, or console.log line),
  • dashboard URL fallback (/ui/login?magic_error=... when unset),
  • refresh survival (second hit on consumed token does not re-mint).

The route-wiring smoke is at src/core/__tests__/server.test.ts::"GET /auth/magic".

Out of scope (deferred to PR-4 dashboard mint)

  • The dashboard side of the round-trip (the GET /api/magic-link?agent_id=<x> route that mints + 302s) lands in PR-4 in the dashboard repo.
  • Logout flow (POST /ui/logout) deferred per architect §9; this PR ships the entry path only.
  • Cross-device session sync, sliding-window cookie refresh, and operator phantomctl revoke-magic-link are all post-v0 enhancements per architect §13.

Architect doc

Internal architecture and decision rationale (token shape, JWT-vs-opaque, wildcard-vs-per-slug cookie scope, threat model) are in the architect doc referenced from CLAUDE.md. The cross-repo wire shape is the only contract a reviewer needs to verify; this PR is faithful to it.

Inject per-tenant identity into the agent's system prompt so it knows its
own URL, owner, dashboard, runtime, and model. The overlay slots between
Identity and Environment, reads non-secret env vars stamped into the
process by phantom-firstboot, and degrades to the empty string in
single-tenant or laptop dev mode.

Vars consumed: PHANTOM_TENANT_SLUG, PHANTOM_TENANT_ID, PHANTOM_OWNER_EMAIL,
PHANTOM_OWNER_NAME, PHANTOM_DOMAIN, PHANTOM_DASHBOARD_URL,
PHANTOM_AGENT_RUNTIME, PHANTOM_MODEL, plus the PHANTOM_GRANTED_INTEGRATIONS
(Phase 7) and PHANTOM_CHANNEL_ALLOWLIST (Phase 8b) hooks. Every line is
gated on a real value so missing optional vars silently disappear; the
block never carries provider keys or secrets.

Tests: 23 unit tests on the builder + reader + 4 integration tests on the
assembler covering position (Identity < overlay < Environment), defensive
shape (slug + dashboard only), full-env shape, and the integration hooks.
Failure-path coverage for missing env vars, whitespace-only inputs,
malformed list values, and the no-tenant fallback.

Mission v1 sequencing step 4 (master plan section 3 Phase 9). No
phantomd / phantom-rootfs change required: the overlay is defensive
against intermediate phantomd versions that have not yet landed
PHANTOM_DOMAIN and PHANTOM_OWNER_NAME injection.
…R-3)

Adds the in-VM landing route that the dashboard redirects users to
after minting a one-time magic-link token via phantom-control. The
handler validates the token server-side via the metadata gateway
(http://169.254.169.254/v1/magic-link/validate), enforces a
defense-in-depth chain (token shape, per-IP rate limit, tenant-config
sanity, cross-tenant slug check, owner-email binding), and mints a
phantom_session cookie scoped to the per-tenant subdomain before
redirecting to /chat.

- New src/ui/auth-magic.ts with handleAuthMagic (~360 LOC).
- Wired in src/core/server.ts at top-level /auth/magic (not /ui/*,
  the token IS the auth so the route bypasses the cookie gate by
  design).
- Per-IP rate limit: 10 / IP / minute, sliding window. Successful
  validates do not bump the budget.
- Cookie: Domain=<slug>.phantom.ghostwright.dev, Path=/, HttpOnly,
  Secure, SameSite=Lax, Max-Age=7d (matches existing session.ts TTL).
  Per-slug not wildcard; Lax not Strict.
- Token plaintext never echoed in response body, redirect Location,
  upstream URL path/query, or any structured-log call.
- Open-redirect defense on ?redirect= (path-only; rejects https://,
  protocol-relative //, and backslash tricks).
- Every error path 302s to ${PHANTOM_DASHBOARD_URL}?magic_error=<code>
  so the dashboard surfaces a Sonner toast. Codes: invalid_token,
  rate_limited, expired, validator_unavailable, owner_mismatch,
  agent_mismatch, tenant_unconfigured.
- 45 new tests at src/ui/__tests__/auth-magic.test.ts covering happy
  path, every failure code, plaintext-leak guards, rate-limit
  semantics, cross-tenant + owner-email gates, open-redirect defense,
  refresh survival.
- 2 route-wiring smoke tests at src/core/__tests__/server.test.ts.
- CLAUDE.md updated with the magic-link callback section.

bun test 2199 pass / 0 fail. bun run lint clean. bun run typecheck
clean.
@mcheemaa
Copy link
Copy Markdown
Member Author

mcheemaa commented May 1, 2026

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c8eec7900f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ui/auth-magic.ts
evictBloatedIfNeeded(cutoff);
return false;
}
kept.push(now);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude successful auths from rate-limit counters

The limiter currently records every syntactically valid attempt before validation, including successful sign-ins, because checkIpRateLimit() always appends now when under the cap. That contradicts the handler’s documented behavior (“successful validates do NOT bump the budget”) and causes legitimate users to hit rate_limited after ~10 normal sign-ins within a minute (e.g., repeated retries from dashboard redirects or multi-tab clicks), even without probing activity.

Useful? React with 👍 / 👎.

@mcheemaa mcheemaa merged commit e82b708 into main May 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant