← back to Kickbacks Ai Upstream

src/auth/client.ts

420 lines

import * as vscode from "vscode";
import { randomBytes } from "node:crypto";
import { homedir } from "node:os";
import { join } from "node:path";
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { createVault, type SecretVault } from "./vault";
import { dlog } from "../log";
import { isLoopbackBase } from "../util/loopback";
import { timeoutFetch } from "../util/http";

type Fetch = typeof fetch;
// kickbacks.* are the current keys; vibe-ads.* are legacy (pre-W1-rename)
// and read-through-only — copied forward on first access so the user's stable
// device id, refresh token, and access token survive the rename. We never
// DELETE the legacy entries; that way a downgrade still finds a session.
const A = "kickbacks.access", R = "kickbacks.refresh", CID = "kickbacks.clientId";
const A_LEGACY = "vibe-ads.access", R_LEGACY = "vibe-ads.refresh";
const CID_LEGACY = "vibe-ads.clientId";
// A vault-sealed refresh value; anything NOT matching is a pre-vault legacy
// plaintext token written by an older build (upgraded in place on first read).
const ENVELOPE = /^(plain|keychain|dpapi|libsecret):1:/;

interface Fallback { refresh?: string; clientId?: string }

/** S1 backend-brokered sign-in. Cross-platform persistence is LAYERED:
 *
 *   1. VS Code SecretStorage (ctx.secrets) — fast cache. Durable on macOS/
 *      Windows; a coin-flip on Linux (no Secret Service on headless/WSL/
 *      devcontainer/Remote-SSH). NEVER the source of truth.
 *   2. id-independent home file (~/.vibe-ads/auth.json, 0600) — the universal
 *      floor; behaves identically on all 3 OSes and survives reinstall/rename.
 *      The refresh token in it is sealed by `SecretVault` (Keychain on macOS,
 *      DPAPI on Windows, libsecret on Linux, plaintext floor otherwise).
 *
 *  The file is ALWAYS written when we hold a refresh token (the old `if
 *  (refresh)`-only bug created the "signed in last session but shows signed
 *  out" gap on keyring-less Linux). S1's refresh role/email loss is ACCEPTED. */
export class AuthClient {
  private at: string | null = null;
  private keyringOk: boolean | undefined; // set by the post-store read-back
  // Single-flight guard: coalesce concurrent refresh() callers (status-bar
  // earnings 401 + portfolio 401 + the load-time re-mint) onto ONE in-flight
  // request. S1 ROTATES (consumes) the refresh token, so two parallel
  // refreshes race — the first rotates the token, the second sends the
  // now-consumed token, 401s, and nulls `at`, clobbering the first call's
  // success. That race is the most likely cause of "signed out after a
  // self-update restart".
  private refreshInFlight: Promise<boolean> | null = null;
  // Single-flight guard for the INTERACTIVE flow (mirrors refreshInFlight):
  // a second "Sign in" click while the browser tab is still loading must
  // join the in-flight flow, not mint a second `state` + parallel 3-minute
  // poll loop that ends in a false "timed out" toast after the user already
  // signed in through the first one.
  private signInInFlight: Promise<boolean> | null = null;
  private secretsStoreWarned = false; // log keyring store failures once
  // Login trigger: fired once on a successful interactive sign-in so the
  // injection patch is reasserted immediately rather than waiting up to 60s
  // for the next reassert tick. Wired by extension.ts; no-op until then.
  private onSignedIn: (() => void) | null = null;
  setOnSignedIn(fn: () => void): void { this.onSignedIn = fn; }
  constructor(private base: string,
              private ctx: vscode.ExtensionContext,
              private f: Fetch = timeoutFetch(15000),
              private pollMs = 1500,
              // ~/.kickbacks/auth.json is the new universal floor. If only the
              // legacy ~/.vibe-ads/auth.json exists we migrate-on-read inside
              // readFallback(); the legacy file is left in place for downgrade.
              private authFile = join(homedir(), ".kickbacks", "auth.json"),
              private vault: SecretVault = createVault(process.platform),
              // Defined AFTER `vault` to preserve the constructor's positional
              // parameter order — existing call sites that pass `vault` as the
              // 6th arg keep working without test churn.
              private legacyAuthFile = join(homedir(), ".vibe-ads", "auth.json")) {}

  accessToken(): string | null { return this.at; }
  signedIn(): boolean { return this.at != null; }

  /** For `vibe-ads.status` / debug menu: which at-rest scheme is in use and
   *  whether the OS keyring actually round-tripped (the original "didn't catch
   *  that I was signed in" was an undetected keyring-less environment). */
  storageInfo(): { scheme: string; keyringDurable?: boolean } {
    return { scheme: this.vault.scheme(), keyringDurable: this.keyringOk };
  }

  /** Full sign-out. Must clear EVERY place a session can be recovered from or
   *  loadCached() silently re-mints and the user can never log out: in-memory
   *  token, both SecretStorage keys, the OS-store entry behind the envelope
   *  (Keychain/libsecret), AND the refresh field in the file. The stable anon
   *  clientId is intentionally KEPT (device id, not auth). Never throws. */
  async signOut(): Promise<void> {
    this.at = null;
    // Revoke server-side FIRST (BL-188): resolve the raw refresh token before
    // the clears below destroy every copy, then fire the revocation without
    // awaiting it — sign-out must stay instant and must succeed offline. The
    // backend deletes the token record, so a copy of the rotating token
    // (stolen file, forgotten machine) can no longer mint sessions for the
    // rest of its TTL after the user signed out.
    try {
      let rt = (await this.ctx.secrets.get(R)) || undefined;
      if (!rt) {
        const stored = this.readFallback().refresh;
        if (stored && ENVELOPE.test(stored)) {
          rt = (await this.vault.open(stored)) || undefined;
        } else if (stored) {
          rt = stored;
        }
      }
      if (rt) {
        this.f(`${this.base}/v1/auth/signout`, {
          method: "POST", headers: { "content-type": "application/json" },
          body: JSON.stringify({ refresh_token: rt }),
        }).then((r) => dlog("ext", "auth.revoke", { ok: r.ok, status: r.status }))
          .catch((e) => dlog("ext", "auth.revoke",
            { ok: false, msg: e instanceof Error ? e.message : String(e) }));
      }
    } catch { /* revocation is defense-in-depth; never block sign-out */ }
    const env = this.readFallback().refresh;
    try { await this.ctx.secrets.delete(A); } catch { /* best-effort */ }
    try { await this.ctx.secrets.delete(R); } catch { /* best-effort */ }
    // Also clear legacy vibe-ads.* keys so a stale token can't re-sign-in.
    try { await this.ctx.secrets.delete(A_LEGACY); } catch { /* best-effort */ }
    try { await this.ctx.secrets.delete(R_LEGACY); } catch { /* best-effort */ }
    if (env && ENVELOPE.test(env)) {
      try { await this.vault.clear(env); } catch { /* best-effort */ }
    }
    try {
      const fb = this.readFallback();
      delete fb.refresh;                       // keep clientId
      mkdirSync(join(this.authFile, ".."), { recursive: true, mode: 0o700 });
      writeFileSync(this.authFile, JSON.stringify(fb), { mode: 0o600 });
      try { chmodSync(this.authFile, 0o600); } catch { /* best-effort */ }
    } catch { /* mirror clear is best-effort; never break the extension */ }
    dlog("ext", "auth.signout", {});
  }

  /** SecretStorage writes are BEST-EFFORT: a locked/absent Secret Service
   *  (the exact keyring-less Linux env the file fallback exists for) makes
   *  ctx.secrets.store() THROW. An unwrapped throw aborted activation from
   *  loadCached and turned a server-side-successful refresh into a sign-out
   *  after the rotating token was already consumed. The sealed file
   *  (sealToFile) is the durable layer; this cache write never throws. */
  private async storeSecret(key: string, value: string): Promise<boolean> {
    try { await this.ctx.secrets.store(key, value); return true; }
    catch (e) {
      if (!this.secretsStoreWarned) {
        this.secretsStoreWarned = true;
        dlog("ext", "auth.secrets.store-failed",
          { error: e instanceof Error ? e.message : String(e) });
      }
      return false;
    }
  }

  // --- id-independent fallback (best-effort; never throws) -----------------
  private readFallback(): Fallback {
    try { return JSON.parse(readFileSync(this.authFile, "utf8")) as Fallback; }
    catch { /* fall through to legacy */ }
    try { return JSON.parse(readFileSync(this.legacyAuthFile, "utf8")) as Fallback; }
    catch { return {}; }
  }
  private writeFallback(patch: Fallback): void {
    try {
      const merged = { ...this.readFallback(), ...patch };
      mkdirSync(join(this.authFile, ".."), { recursive: true, mode: 0o700 });
      writeFileSync(this.authFile, JSON.stringify(merged), { mode: 0o600 });
      try { chmodSync(this.authFile, 0o600); } catch { /* best-effort */ }
    } catch { /* persistence is best-effort; never break the extension */ }
  }

  clientId(): string {
    const fileId = this.readFallback().clientId;
    let id = this.ctx.globalState.get<string>(CID)
      || this.ctx.globalState.get<string>(CID_LEGACY)  // W1: pre-rename users
      || fileId;
    if (!id) id = randomBytes(12).toString("hex");
    this.ctx.globalState.update(CID, id);
    // Write the file ONLY when its clientId is missing/stale. This runs on
    // EVERY metrics send; an unconditional read-merge-write of auth.json can
    // resurrect a just-rotated refresh envelope another window sealed between
    // our read and our write (the rotated token is single-use — clobbering it
    // signs the user out). writeFallback re-reads at write time and merges,
    // so the token envelope on disk is preserved, never rewritten from here.
    if (fileId !== id) this.writeFallback({ clientId: id }); // stable across reinstall/rename
    return id;
  }

  async loadCached(): Promise<void> {
    if (this.devBypassEnabled()) {
      this.at = "dev-bypass";
      dlog("ext", "auth.loadCached",
        { hadAccess: true, refreshSource: "dev-bypass", signedIn: true });
      return;
    }
    // W1 rename: try kickbacks.* first, fall back to legacy vibe-ads.* keys.
    this.at = (await this.ctx.secrets.get(A))
      || (await this.ctx.secrets.get(A_LEGACY))
      || null;
    // Recover the refresh token from the id-independent file if the
    // SecretStorage namespace was lost (reinstall / rename / keyring-less).
    let rt = (await this.ctx.secrets.get(R))
      || (await this.ctx.secrets.get(R_LEGACY)) || undefined;
    let rtSource = rt ? "secrets" : "none";
    if (!rt) {
      const stored = this.readFallback().refresh;
      if (stored && ENVELOPE.test(stored)) {
        rt = (await this.vault.open(stored)) || undefined; // sealed (current)
      } else if (stored) {
        // Pre-vault plaintext token from an older build: use it, then
        // upgrade-in-place by re-sealing through the vault.
        rt = stored;
        await this.sealToFile(rt);
      }
      if (rt) { rtSource = "file"; await this.storeSecret(R, rt); } // re-warm
    }
    // No access token but we have a refresh token => re-mint silently
    // (this is what makes a reinstall NOT require a new Google sign-in).
    // Hand the recovered token to refresh() EXPLICITLY: relying on it to
    // re-read ctx.secrets is the gap that breaks recovery when the store()
    // above doesn't durably round-trip in the same activation (keyring-less
    // box / fresh post-reinstall namespace).
    if (!this.at && rt) await this.refresh(rt);
    dlog("ext", "auth.loadCached",
      { hadAccess: this.at != null, refreshSource: rtSource,
        signedIn: this.signedIn() });
  }

  /** Seal a refresh token via the OS-native vault and ALWAYS persist the
   *  resulting envelope to the universal file. Best-effort; never throws. */
  private async sealToFile(refresh: string): Promise<void> {
    try {
      const env = await this.vault.seal(this.clientId(), refresh);
      this.writeFallback({ refresh: env });
    } catch { /* persistence is best-effort; never break the extension */ }
  }

  private async persistTokens(access: string, refresh?: string): Promise<void> {
    this.at = access;
    const stored = await this.storeSecret(A, access);
    // Keyring health probe: if the store doesn't round-trip we're in a
    // keyring-less env and the file (above-vault) is doing the real work.
    if (!stored) this.keyringOk = false;
    else {
      try { this.keyringOk = (await this.ctx.secrets.get(A)) === access; }
      catch { this.keyringOk = false; }
    }
    if (refresh) {
      await this.storeSecret(R, refresh); // cache only
      await this.sealToFile(refresh); // ALWAYS — the durable source of truth
    }
  }

  /** Interactive sign-in. Concurrent calls (double-click, palette + nudge)
   *  are coalesced onto ONE browser tab + poll loop — a parallel loop polls
   *  a state the user never completes and ends in a false "timed out" toast
   *  minutes after the real flow succeeded. */
  async signIn(): Promise<boolean> {
    if (this.signInInFlight) return this.signInInFlight;
    const p = this._signIn();
    this.signInInFlight = p;
    try { return await p; }
    finally { if (this.signInInFlight === p) this.signInInFlight = null; }
  }

  private async _signIn(): Promise<boolean> {
    try {
      // S1 contract (see tools/ext_auth_harness.py): /extension/start 307-
      // redirects to Google with `state` embedded in the Location URL. Do NOT
      // follow the redirect (must not fetch Google's HTML) — read Location,
      // open it in the system browser, poll with the parsed state.
      const start = await this.f(`${this.base}/v1/auth/extension/start`,
        { redirect: "manual" } as RequestInit);
      const loc = start.headers.get("location");
      if (!loc) { dlog("ext", "auth.signin", { ok: false, reason: "no-location" }); return false; }
      const state = new URL(loc).searchParams.get("state");
      if (!state) { dlog("ext", "auth.signin", { ok: false, reason: "no-state" }); return false; }
      await vscode.env.openExternal(vscode.Uri.parse(loc));
      const wasSignedIn = this.signedIn();
      for (let i = 0; i < 120; i++) {
        // Signed in through another path mid-poll (background refresh,
        // loadCached re-mint)? Exit silently — the session is live, and an
        // error toast after a successful sign-in reads as a broken product.
        if (!wasSignedIn && this.signedIn()) {
          dlog("ext", "auth.signin", { ok: true, reason: "signed-in-elsewhere" });
          return true;
        }
        const r = await this.f(
          `${this.base}/v1/auth/extension/poll?state=${encodeURIComponent(state)}`);
        const j = await r.json() as { status?: string; access_token?: string;
          refresh_token?: string };
        if (j.access_token) {
          await this.persistTokens(j.access_token, j.refresh_token);
          dlog("ext", "auth.signin", { ok: true });
          try { this.onSignedIn?.(); } catch { /* trigger is best-effort */ }
          return true;
        }
        await new Promise((res) => setTimeout(res, this.pollMs));
      }
      if (!wasSignedIn && this.signedIn()) {
        dlog("ext", "auth.signin", { ok: true, reason: "signed-in-elsewhere" });
        return true;
      }
      dlog("ext", "auth.signin", { ok: false, reason: "timeout" });
      vscode.window.showErrorMessage(
        "Vibe-Ads sign-in timed out: no token after polling. " +
        "Did you complete the Google consent in the browser?");
      return false;
    } catch (e) {
      dlog("ext", "auth.signin", { ok: false, reason: "error" });
      vscode.window.showErrorMessage(
        `Vibe-Ads sign-in failed: ${e instanceof Error ? e.message : String(e)}`);
      return false;
    }
  }

  /** Re-mint the access token from the refresh token. Concurrent callers are
   *  coalesced (single-flight) so the rotating refresh token is consumed at
   *  most once per cycle. `explicitRt` lets loadCached() hand the just-
   *  recovered token straight in, bypassing a SecretStorage round-trip that
   *  may not have landed yet. Never throws. */
  async refresh(explicitRt?: string): Promise<boolean> {
    if (this.devBypassEnabled()) {
      this.at = "dev-bypass";
      dlog("ext", "auth.refresh", { ok: true, reason: "dev-bypass" });
      return true;
    }
    if (this.refreshInFlight) return this.refreshInFlight;
    const p = this._refresh(explicitRt);
    this.refreshInFlight = p;
    try { return await p; }
    finally { if (this.refreshInFlight === p) this.refreshInFlight = null; }
  }

  private async _refresh(explicitRt?: string): Promise<boolean> {
    // wave-2A-H1 + audit #10: only an EXPLICIT server rejection (401/403 or
    // an invalid_grant-style body — the session is dead) clears `this.at`,
    // flipping signedIn() to false at the right moment. A TRANSIENT failure
    // (network throw, timeout, 5xx) says nothing about token validity:
    // pre-fix it nulled a possibly-valid access token and — because every
    // refresh() caller is gated on accessToken() — one offline blip during
    // the activation-time forced refresh demoted the whole session to demo
    // (user-credit loss) with zero retry paths. On transient failure we keep
    // the current tokens and return false; the next caller (60s rotation,
    // earnings 401 retry) retries naturally. The lost-response rotation race
    // (server rotated, reply dropped) is covered server-side by the
    // idempotent reuse-grace window.
    try {
      // Token source order: an explicitly-handed token (load-time recovery),
      // then the fast SecretStorage cache, then the durable sealed file. The
      // file fallback closes the gap where secrets is empty/stale (reinstall,
      // keyring-less) but the file still holds a usable — possibly newer —
      // token (e.g. a prior store() silently failed but sealToFile() landed).
      let rt = explicitRt || (await this.ctx.secrets.get(R)) || undefined;
      if (!rt) {
        const stored = this.readFallback().refresh;
        if (stored && ENVELOPE.test(stored)) rt = (await this.vault.open(stored)) || undefined;
        else if (stored) rt = stored;
      }
      if (!rt) {
        // No refresh token anywhere: nothing can ever re-mint this session.
        this.at = null;
        dlog("ext", "auth.refresh", { ok: false, reason: "no-token" });
        return false;
      }
      const r = await this.f(`${this.base}/v1/auth/refresh`, {
        method: "POST", headers: { "content-type": "application/json" },
        body: JSON.stringify({ refresh_token: rt }),
      });
      if (!r.ok) {
        if (await this.isExplicitRejection(r)) {
          this.at = null;
          dlog("ext", "auth.refresh", { ok: false, reason: `http-${r.status}` });
        } else {
          // 5xx / 429 / gateway noise: the token may be fine — keep it.
          dlog("ext", "auth.refresh",
            { ok: false, reason: `http-${r.status}`, transient: true });
        }
        return false;
      }
      const j = await r.json() as { access_token?: string; refresh_token?: string };
      if (!j.access_token) {
        // A 2xx without a token is a server/proxy anomaly, not a rejection.
        dlog("ext", "auth.refresh",
          { ok: false, reason: "no-access-token", transient: true });
        return false;
      }
      // S1 /refresh ROTATES the refresh token (consume + reissue); persist the
      // new one or the next refresh fails with a consumed token.
      await this.persistTokens(j.access_token, j.refresh_token);
      dlog("ext", "auth.refresh", { ok: true, rotated: !!j.refresh_token });
      return true;
    } catch {
      // Network / DNS / timeout: transient — keep the current tokens so a
      // later refresh can succeed (audit #10).
      dlog("ext", "auth.refresh",
        { ok: false, reason: "throw", transient: true });
      return false;
    }
  }

  /** An EXPLICIT auth rejection: the server saw the refresh token and refused
   *  it. Our backend 401s a consumed/unknown token; 403 and an OAuth-style
   *  `invalid_grant` 4xx body (defensive — proxies / future backends) also
   *  count. Anything else non-ok (5xx, 429, gateway noise) is transient and
   *  must NOT discard tokens. Never throws. */
  private async isExplicitRejection(r: Response): Promise<boolean> {
    if (r.status === 401 || r.status === 403) return true;
    if (r.status >= 500) return false;
    try {
      const body = (await r.clone?.().text?.()) ?? "";
      return /invalid_grant/i.test(body);
    } catch { return false; }
  }

  private devBypassEnabled(): boolean {
    const on = process.env.KICKBACKS_DEV_BYPASS === "1"
      || process.env.VIBE_ADS_DEV_BYPASS === "1";
    return on && isLoopbackBase(this.base);
  }
}