XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
UTF-8
/**
 * g4f extension — OAuth sign-in for g4f.space (PKCE authorization-code flow).
 *
 * Wire format (see g4f.dev/workers/members-worker.js):
 *   authorize: GET https://auth.g4f.space/members/oauth/authorize
 *       ?response_type=code&client_id=g4f-web&redirect_uri=...&state=...
 *       &code_challenge=<S256(verifier)>&code_challenge_method=S256
 *   token:     POST https://auth.g4f.space/members/oauth/token
 *       grant_type=authorization_code&client_id=g4f-web&client_secret=...
 *       &code=...&redirect_uri=...&code_verifier=...
 *       -> { access_token, token_type, expires_in, scope, user: {...} }
 *
 * The token endpoint returns a temporary login API key as `access_token`
 * plus the full user record (id, secret, name, tier, ...). The workspace
 * secret used for encrypted conversation sync is derived client-side:
 *     hex( SHA-256( `${user.id}:${user.secret}` ) )
 *
 * Redirect handling — the auth worker only accepts redirects on
 * localhost / 127.0.0.1 / g4f.dev / *.g4f.space (see `isValidRedirect` in
 * members-worker.js), so `https://<ext-id>.chromiumapp.org/` is NOT allowed.
 * We therefore run a tab-based flow with a loopback redirect:
 *   - authorize URL opens in a regular tab (login chooser: GitHub/Discord/…)
 *   - after sign-in the server redirects to http://127.0.0.1:<port>/oauth/callback?code=…
 *   - the extension already holds host permissions for 127.0.0.1 on any
 *     port, so chrome.tabs.onUpdated exposes the final URL and we capture
 *     the code (nothing needs to actually listen on that port).
 */

import { STORAGE_KEYS } from "./constants.js";

const AUTH_BASE = "https://auth.g4f.space";
const CLIENT_ID = "g4f-web";
// Public-by-design first-party client secret (browser clients cannot hold
// secrets; PKCE protects the code exchange).
const CLIENT_SECRET = "5594a516-0da6-4167-bcaa-132e715c54a3";
// Loopback redirect — hostname is in the worker's allow-list; the port is
// arbitrary (the tab may show a 404/refused page, we only need the URL).
const REDIRECT_URI = "http://127.0.0.1:1337/oauth/callback";
/** storage key holding a content-script handoff while the flow is pending */
const CB_KEY = "g4fOAuthCallback";

/* ------------------------------------------------------------------ */
/* PKCE helpers                                                        */
/* ------------------------------------------------------------------ */

function randomString(len = 64) {
  const bytes = new Uint8Array(len);
  crypto.getRandomValues(bytes);
  return Array.from(bytes, (b) => b.toString(36).padStart(2, "0")).join("").slice(0, len);
}

function base64Url(buffer) {
  const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
  let bin = "";
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

async function sha256Hex(str) {
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
}

async function codeChallenge(verifier) {
  return base64Url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
}

/* ------------------------------------------------------------------ */
/* Session storage (chrome.storage.local)                              */
/* ------------------------------------------------------------------ */

export async function getSession() {
  const res = await chrome.storage.local.get([
    STORAGE_KEYS.user, STORAGE_KEYS.token, STORAGE_KEYS.expires, STORAGE_KEYS.workspaceSecret,
  ]);
  const user = res[STORAGE_KEYS.user] || null;
  if (!user) return null;
  return {
    user,
    token: res[STORAGE_KEYS.token] || "",
    expires: res[STORAGE_KEYS.expires] || 0,
    workspaceSecret: res[STORAGE_KEYS.workspaceSecret] || "",
  };
}

export async function setSession(session) {
  await chrome.storage.local.set({
    [STORAGE_KEYS.user]: session.user,
    [STORAGE_KEYS.token]: session.token || "",
    [STORAGE_KEYS.expires]: session.expires || 0,
    [STORAGE_KEYS.workspaceSecret]: session.workspaceSecret || "",
  });
}

export async function clearSession() {
  await chrome.storage.local.remove([
    STORAGE_KEYS.user, STORAGE_KEYS.token, STORAGE_KEYS.expires, STORAGE_KEYS.workspaceSecret,
  ]);
}

export function isExpired(session) {
  return !session || !session.token || (session.expires > 0 && Date.now() / 1000 > session.expires - 60);
}

/** Derive the workspace secret from the user record. */
export async function deriveWorkspaceSecret(user) {
  return sha256Hex(`${user.id}:${user.secret}`);
}

/* ------------------------------------------------------------------ */
/* Sign-in / sign-out                                                  */
/* ------------------------------------------------------------------ */

/**
 * Run the full OAuth flow. Resolves with the session or throws.
 * Tab-based loopback flow (see header comment for why launchWebAuthFlow
 * cannot be used: chromiumapp.org is not in the auth worker allow-list).
 */
export async function signIn() {
  const state = randomString(24);
  const verifier = randomString(64);
  const challenge = await codeChallenge(verifier);

  const params = new URLSearchParams({
    response_type: "code",
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
  });

  const code = await tabFlow(`${AUTH_BASE}/members/oauth/authorize?${params}`, state);
  if (!code) throw new Error("Sign-in cancelled or timed out.");

  const session = await exchangeCode(code, verifier, REDIRECT_URI);
  await setSession(session);
  return session;
}

export async function signOut() {
  // Best-effort server-side logout; ignore failures.
  try {
    const session = await getSession();
    if (session?.token) {
      await fetch(`${AUTH_BASE}/members/api/logout`, {
        headers: { Authorization: "Bearer " + session.token },
      });
    }
  } catch { /* offline — clear locally anyway */ }
  await clearSession();
}

/* ------------------------------------------------------------------ */
/* Internals                                                           */
/* ------------------------------------------------------------------ */

function parseCode(redirectUrl, state) {
  if (!redirectUrl) return null;
  try {
    const url = new URL(redirectUrl);
    if (url.searchParams.get("state") !== state) return null;
    return url.searchParams.get("code");
  } catch {
    return null;
  }
}

/**
 * Tab-based loopback flow: open the authorize page in a new tab. After the
 * user signs in, the auth server redirects to REDIRECT_URI (127.0.0.1) with
 * ?code=…&state=…. Because the extension holds host_permissions for
 * 127.0.0.1:*, chrome.tabs.onUpdated exposes the URL even though nothing
 * is listening on that port. A session-storage handoff (CB_KEY) is polled
 * as a secondary path for robustness.
 */
async function tabFlow(authorizeUrl, state) {
  const tab = await chrome.tabs.create({ url: authorizeUrl, active: true });
  const deadline = Date.now() + 5 * 60 * 1000; // 5 min

  return new Promise((resolve) => {
    let done = false;
    const finish = (code) => {
      if (done) return;
      done = true;
      chrome.tabs.onUpdated.removeListener(onUpdated);
      chrome.tabs.onRemoved.removeListener(onRemoved);
      chrome.storage.session.remove(CB_KEY).catch(() => {});
      resolve(code);
      // Close the sign-in tab once we have the code.
      if (code && tab?.id != null) chrome.tabs.remove(tab.id).catch(() => {});
    };

    function onUpdated(tabId, change) {
      if (tabId !== tab.id || done) return;
      const url = change?.url || "";
      if (!url.includes("code=")) return;
      const code = parseCode(url, state);
      if (code) finish(code);
    }
    function onRemoved(removedId) {
      if (removedId === tab.id) finish(null);
    }

    chrome.tabs.onUpdated.addListener(onUpdated);
    chrome.tabs.onRemoved.addListener(onRemoved);

    // Secondary path: poll for a handoff written by a content script.
    const poll = setInterval(async () => {
      if (done) { clearInterval(poll); return; }
      if (Date.now() > deadline) { clearInterval(poll); finish(null); return; }
      try {
        const { [CB_KEY]: cb } = await chrome.storage.session.get(CB_KEY);
        if (cb?.code && cb.state === state) finish(cb.code);
      } catch { /* ignore */ }
    }, 1000);
  });
}

/** Exchange an authorization code for a session. */
async function exchangeCode(code, verifier, redirectUri) {
  const body = new URLSearchParams({
    grant_type: "authorization_code",
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    code,
    redirect_uri: redirectUri,
    code_verifier: verifier,
  });
  const res = await fetch(`${AUTH_BASE}/members/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });
  if (!res.ok) {
    let msg = `Token exchange failed (HTTP ${res.status})`;
    try {
      const err = await res.json();
      if (err?.error_description) msg = err.error_description;
      else if (err?.error) msg = err.error;
    } catch { /* keep default */ }
    throw new Error(msg);
  }
  const data = await res.json();
  if (!data?.access_token || !data?.user) {
    throw new Error("Token response missing access_token/user.");
  }
  return {
    user: data.user,
    token: data.access_token,
    expires: Date.now() / 1000 + (data.expires_in || 3600),
    workspaceSecret: await deriveWorkspaceSecret(data.user),
  };
}