24 KiB

Logto Auth Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add Logto-based Chrome extension authentication with a popup login entry, background-owned auth flow, and content-script gating so the Xingtu enhancer is unusable until the user is authenticated.

Architecture: Keep Logto-specific behavior inside src/background/auth/* and expose a small message contract from src/shared/auth-messages.ts. Add a standalone popup entry for sign-in / sign-out and dev diagnostics. Gate existing market content initialization behind an auth check so the page only boots the current enhancer after authentication succeeds.

Tech Stack: Chrome MV3, TypeScript, Vitest, tsup build script, @logto/chrome-extension


File Map

New files

  • src/shared/auth-config.ts
  • src/shared/auth-messages.ts
  • src/background/auth/types.ts
  • src/background/auth/state.ts
  • src/background/auth/client.ts
  • src/background/auth/controller.ts
  • src/popup/index.html
  • src/popup/index.ts
  • src/popup/view.ts
  • src/content/market/auth-gate.ts
  • tests/auth-config.test.ts
  • tests/auth-messages.test.ts
  • tests/background-auth-controller.test.ts
  • tests/popup-entry.test.ts
  • tests/market-auth-gating.test.ts

Existing files to modify

  • package.json
  • package-lock.json
  • src/manifest.json
  • scripts/build.mjs
  • src/background/index.ts
  • src/content/index.ts
  • src/content/market/index.ts
  • tests/background-index.test.ts
  • tests/manifest.test.ts
  • README.md
  • externaldocs/2026-04-21-logto-auth-design.md

Responsibilities

  • src/shared/auth-config.ts: parse placeholder config, validate required fields, expose a stable config object.
  • src/shared/auth-messages.ts: define request / response types and type guards for popup-content-background communication.
  • src/background/auth/*: isolate Logto client creation, auth state mapping, and runtime message handling.
  • src/popup/*: render login/logout UI and dev diagnostics using background messages only.
  • src/content/market/auth-gate.ts: render the disabled state and “go login” guidance when auth is missing.

Task 1: Shared Auth Contract And Config

Files:

  • Create: src/shared/auth-config.ts

  • Create: src/shared/auth-messages.ts

  • Test: tests/auth-config.test.ts

  • Test: tests/auth-messages.test.ts

  • Modify: package.json

  • Modify: package-lock.json

  • Step 1: Add the Logto SDK dependency in the manifest of the repo

Update package.json dependencies to include:

{
  "dependencies": {
    "@logto/chrome-extension": "^<latest-compatible>"
  }
}

Run: npm install Expected: package-lock.json includes @logto/chrome-extension

  • Step 2: Write the failing config tests

Create tests/auth-config.test.ts with cases for:

import { describe, expect, test } from "vitest";

import { readAuthConfig } from "../src/shared/auth-config";

describe("auth-config", () => {
  test("returns the placeholder config in development", () => {
    expect(readAuthConfig()).toEqual({
      apiResource: "<your-global-api-resource-indicator>",
      appId: "<chrome-extension-app-id>",
      enableDevAuthPanel: false,
      logtoEndpoint: "https://<your-tenant>.logto.app",
      scopes: ["openid", "profile", "offline_access"]
    });
  });

  test("rejects empty endpoint values", () => {
    expect(() =>
      readAuthConfig({
        logtoEndpoint: ""
      })
    ).toThrow(/logtoEndpoint/i);
  });
});

Run: npm test -- tests/auth-config.test.ts Expected: FAIL because readAuthConfig does not exist yet

  • Step 3: Write the failing message contract tests

Create tests/auth-messages.test.ts with cases for:

import { describe, expect, test } from "vitest";

import {
  isAuthRequestMessage,
  isAuthResponseMessage
} from "../src/shared/auth-messages";

describe("auth-messages", () => {
  test("accepts a get-state request", () => {
    expect(isAuthRequestMessage({ type: "auth:get-state" })).toBe(true);
  });

  test("rejects unknown auth requests", () => {
    expect(isAuthRequestMessage({ type: "auth:wat" })).toBe(false);
  });

  test("accepts a successful auth response envelope", () => {
    expect(
      isAuthResponseMessage({
        ok: true,
        type: "auth:state",
        value: { isAuthenticated: false }
      })
    ).toBe(true);
  });
});

Run: npm test -- tests/auth-messages.test.ts Expected: FAIL because helpers and types do not exist yet

  • Step 4: Implement the minimal shared config

Create src/shared/auth-config.ts with:

export interface AuthConfig {
  apiResource: string;
  appId: string;
  enableDevAuthPanel: boolean;
  logtoEndpoint: string;
  scopes: string[];
}

const defaultAuthConfig: AuthConfig = {
  apiResource: "<your-global-api-resource-indicator>",
  appId: "<chrome-extension-app-id>",
  enableDevAuthPanel: false,
  logtoEndpoint: "https://<your-tenant>.logto.app",
  scopes: ["openid", "profile", "offline_access"]
};

export function readAuthConfig(
  overrides: Partial<AuthConfig> = {}
): AuthConfig {
  const nextConfig = { ...defaultAuthConfig, ...overrides };
  if (!nextConfig.logtoEndpoint.trim()) {
    throw new Error("auth config logtoEndpoint is required");
  }
  if (!nextConfig.appId.trim()) {
    throw new Error("auth config appId is required");
  }
  if (!nextConfig.apiResource.trim()) {
    throw new Error("auth config apiResource is required");
  }
  return nextConfig;
}
  • Step 5: Implement the minimal shared message contract

Create src/shared/auth-messages.ts with:

export type AuthRequestMessage =
  | { type: "auth:get-state" }
  | { type: "auth:sign-in" }
  | { type: "auth:sign-out" }
  | { type: "auth:get-access-token" };

export interface AuthStateValue {
  accessTokenExpiresAt?: number | null;
  isAuthenticated: boolean;
  lastError?: string | null;
  resource?: string | null;
  scopes?: string[];
  tokenAvailable?: boolean;
  userInfo?: {
    email?: string;
    name?: string;
    sub?: string;
    username?: string;
  } | null;
}

export type AuthResponseMessage =
  | { ok: true; type: "auth:state"; value: AuthStateValue }
  | { ok: true; type: "auth:token"; value: { accessToken: string } }
  | { ok: true; type: "auth:ack" }
  | { ok: false; type: "auth:error"; error: string };

Also add isAuthRequestMessage() and isAuthResponseMessage() type guards.

  • Step 6: Run the shared auth tests and verify green

Run: npm test -- tests/auth-config.test.ts tests/auth-messages.test.ts Expected: PASS

  • Step 7: Commit the shared contract slice

Run:

git add package.json package-lock.json src/shared/auth-config.ts src/shared/auth-messages.ts tests/auth-config.test.ts tests/auth-messages.test.ts
git commit -m "feat: add shared auth config and message contracts"

Task 2: Background Auth Controller

Files:

  • Create: src/background/auth/types.ts

  • Create: src/background/auth/state.ts

  • Create: src/background/auth/client.ts

  • Create: src/background/auth/controller.ts

  • Modify: src/background/index.ts

  • Test: tests/background-auth-controller.test.ts

  • Modify: tests/background-index.test.ts

  • Step 1: Write the failing background auth controller tests

Create tests/background-auth-controller.test.ts with cases for:

import { describe, expect, test, vi } from "vitest";

import { createAuthController } from "../src/background/auth/controller";

describe("background-auth-controller", () => {
  test("returns unauthenticated state when the client is logged out", async () => {
    const controller = createAuthController({
      authClient: {
        getAccessToken: vi.fn(),
        getIdTokenClaims: vi.fn(),
        isAuthenticated: vi.fn(async () => false),
        signIn: vi.fn(),
        signOut: vi.fn()
      }
    });

    await expect(controller.getAuthState()).resolves.toEqual(
      expect.objectContaining({
        isAuthenticated: false
      })
    );
  });

  test("delegates sign in to the auth client", async () => {
    const signIn = vi.fn(async () => undefined);
    const controller = createAuthController({
      authClient: {
        getAccessToken: vi.fn(),
        getIdTokenClaims: vi.fn(),
        isAuthenticated: vi.fn(async () => false),
        signIn,
        signOut: vi.fn()
      }
    });

    await controller.signIn();
    expect(signIn).toHaveBeenCalledTimes(1);
  });
});

Run: npm test -- tests/background-auth-controller.test.ts Expected: FAIL because the controller does not exist

  • Step 2: Extend the existing background runtime test with auth messages

Modify tests/background-index.test.ts to add:

test("responds to auth:get-state with auth status", async () => {
  const listeners: Array<any> = [];
  const sendResponse = vi.fn();

  registerBackgroundMessageHandler(
    {
      runtime: {
        onMessage: {
          addListener(listener) {
            listeners.push(listener);
          }
        }
      }
    },
    {
      authController: {
        getAccessToken: vi.fn(),
        getAuthState: vi.fn(async () => ({ isAuthenticated: false })),
        signIn: vi.fn(),
        signOut: vi.fn()
      }
    }
  );

  const result = listeners[0]({ type: "auth:get-state" }, {}, sendResponse);
  expect(result).toBe(true);
  await new Promise((resolve) => setTimeout(resolve, 0));
  expect(sendResponse).toHaveBeenCalledWith({
    ok: true,
    type: "auth:state",
    value: { isAuthenticated: false }
  });
});

Run: npm test -- tests/background-index.test.ts Expected: FAIL because registerBackgroundMessageHandler only supports CSV download

  • Step 3: Implement the minimal background auth controller

Create src/background/auth/types.ts:

export interface AuthClientLike {
  getAccessToken(resource?: string): Promise<string>;
  getIdTokenClaims(): Promise<Record<string, unknown> | null>;
  isAuthenticated(): Promise<boolean>;
  signIn(): Promise<void>;
  signOut(): Promise<void>;
}

Create src/background/auth/state.ts with a helper that maps client state into the shared AuthStateValue.

Create src/background/auth/controller.ts with:

export function createAuthController(options: {
  authClient: AuthClientLike;
  config?: AuthConfig;
}) {
  return {
    async getAuthState() {
      const isAuthenticated = await options.authClient.isAuthenticated();
      if (!isAuthenticated) {
        return { isAuthenticated: false, resource: options.config?.apiResource ?? null };
      }
      const claims = await options.authClient.getIdTokenClaims();
      return {
        isAuthenticated: true,
        resource: options.config?.apiResource ?? null,
        scopes: options.config?.scopes ?? [],
        userInfo: {
          email: typeof claims?.email === "string" ? claims.email : undefined,
          name: typeof claims?.name === "string" ? claims.name : undefined,
          sub: typeof claims?.sub === "string" ? claims.sub : undefined,
          username: typeof claims?.username === "string" ? claims.username : undefined
        }
      };
    },
    signIn: () => options.authClient.signIn(),
    signOut: () => options.authClient.signOut(),
    async getAccessToken() {
      return options.authClient.getAccessToken(options.config?.apiResource);
    }
  };
}
  • Step 4: Add the Logto client factory wrapper

Create src/background/auth/client.ts as the only place that imports @logto/chrome-extension:

import LogtoClient from "@logto/chrome-extension";

import { readAuthConfig } from "../../shared/auth-config";

export function createLogtoAuthClient() {
  const config = readAuthConfig();
  return new LogtoClient({
    appId: config.appId,
    endpoint: config.logtoEndpoint,
    resources: [config.apiResource],
    scopes: config.scopes
  });
}
  • Step 5: Integrate auth handling into src/background/index.ts

Refactor registerBackgroundMessageHandler() to accept optional dependencies:

registerBackgroundMessageHandler(chromeLike?, {
  authController?: createAuthController(...)
});

Handle:

  • auth:get-state -> { ok: true, type: "auth:state", value }
  • auth:sign-in -> { ok: true, type: "auth:ack" }
  • auth:sign-out -> { ok: true, type: "auth:ack" }
  • auth:get-access-token -> { ok: true, type: "auth:token", value: { accessToken } }

Keep the existing CSV download path intact.

  • Step 6: Run background tests and verify green

Run: npm test -- tests/background-auth-controller.test.ts tests/background-index.test.ts Expected: PASS

  • Step 7: Commit the background auth slice

Run:

git add src/background/index.ts src/background/auth tests/background-auth-controller.test.ts tests/background-index.test.ts
git commit -m "feat: add background auth controller"

Task 3: Popup Entry, Manifest, And Build Output

Files:

  • Create: src/popup/index.html

  • Create: src/popup/index.ts

  • Create: src/popup/view.ts

  • Modify: src/manifest.json

  • Modify: scripts/build.mjs

  • Modify: tests/manifest.test.ts

  • Test: tests/popup-entry.test.ts

  • Step 1: Write the failing popup rendering tests

Create tests/popup-entry.test.ts with a JSDOM fixture:

import { describe, expect, test, vi } from "vitest";

import { bootPopup } from "../src/popup/index";

describe("popup-entry", () => {
  test("renders a sign-in button when unauthenticated", async () => {
    document.body.innerHTML = "<main id='app'></main>";

    await bootPopup({
      document,
      sendMessage: vi.fn(async () => ({
        ok: true,
        type: "auth:state",
        value: { isAuthenticated: false }
      }))
    });

    expect(document.querySelector("button")?.textContent).toContain("登录");
  });
});

Run: npm test -- tests/popup-entry.test.ts Expected: FAIL because popup files do not exist

  • Step 2: Extend manifest tests for auth permissions and popup

Modify tests/manifest.test.ts to assert:

expect(manifest.permissions).toEqual(
  expect.arrayContaining(["downloads", "identity", "storage"])
);
expect(manifest.action?.default_popup).toBe("popup/index.html");

Run: npm test -- tests/manifest.test.ts Expected: FAIL because manifest does not include popup / auth permissions

  • Step 3: Implement the minimal popup view

Create src/popup/view.ts with rendering helpers:

export function renderLoggedOut(root: HTMLElement, error?: string | null) {
  root.innerHTML = `
    <section data-popup-state="logged-out">
      <h1>Star Chart Search Enhancer</h1>
      <p>登录后才能使用星图增强功能</p>
      ${error ? `<p data-popup-error="true">${error}</p>` : ""}
      <button type="button" data-popup-sign-in="button">登录 Logto</button>
    </section>
  `;
}

Also add renderLoggedIn() and renderDevPanel() helpers.

  • Step 4: Implement the minimal popup bootstrap

Create src/popup/index.ts with:

import { renderLoggedIn, renderLoggedOut } from "./view";

export async function bootPopup(options = {}) {
  const currentDocument = options.document ?? document;
  const root = currentDocument.querySelector("#app") as HTMLElement | null;
  if (!root) {
    throw new Error("popup root #app is required");
  }
  const sendMessage =
    options.sendMessage ??
    ((message: unknown) =>
      chrome.runtime.sendMessage(message) as Promise<unknown>);

  const stateResponse = await sendMessage({ type: "auth:get-state" });
  if (!stateResponse.ok || stateResponse.type !== "auth:state") {
    renderLoggedOut(root, "认证状态读取失败");
    return;
  }

  if (!stateResponse.value.isAuthenticated) {
    renderLoggedOut(root, stateResponse.value.lastError);
    root.querySelector("[data-popup-sign-in='button']")?.addEventListener("click", async () => {
      await sendMessage({ type: "auth:sign-in" });
    });
    return;
  }

  renderLoggedIn(root, stateResponse.value);
}

Create src/popup/index.html:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Star Chart Search Enhancer</title>
  </head>
  <body>
    <main id="app"></main>
    <script src="./index.js"></script>
  </body>
</html>
  • Step 5: Update build and manifest

Modify scripts/build.mjs to:

  • build src/popup/index.ts into dist/popup/index.js
  • create dist/popup
  • copy src/popup/index.html into dist/popup/index.html

Modify src/manifest.json to:

{
  "permissions": ["downloads", "identity", "storage"],
  "action": {
    "default_popup": "popup/index.html"
  }
}

Preserve current background and content script entries.

  • Step 6: Run popup and manifest tests

Run: npm test -- tests/popup-entry.test.ts tests/manifest.test.ts Expected: PASS

  • Step 7: Commit the popup and manifest slice

Run:

git add src/popup src/manifest.json scripts/build.mjs tests/popup-entry.test.ts tests/manifest.test.ts
git commit -m "feat: add popup auth entry and manifest wiring"

Task 4: Content Auth Gating

Files:

  • Create: src/content/market/auth-gate.ts

  • Modify: src/content/index.ts

  • Modify: src/content/market/index.ts

  • Test: tests/market-auth-gating.test.ts

  • Modify: tests/market-content-entry.test.ts

  • Step 1: Write the failing content gating test

Create tests/market-auth-gating.test.ts:

import { describe, expect, test, vi } from "vitest";

import { bootContentScript } from "../src/content/index";

describe("market-auth-gating", () => {
  test("shows a login gate instead of booting the market controller when unauthenticated", async () => {
    document.body.innerHTML = "<div></div>";

    const createMarketController = vi.fn();
    await bootContentScript({
      createMarketController,
      document,
      sendAuthMessage: vi.fn(async () => ({
        ok: true,
        type: "auth:state",
        value: { isAuthenticated: false }
      })),
      window
    });

    expect(createMarketController).not.toHaveBeenCalled();
    expect(document.body.textContent).toContain("请先登录插件");
  });
});

Run: npm test -- tests/market-auth-gating.test.ts Expected: FAIL because bootContentScript does not gate on auth yet

  • Step 2: Add an integration assertion to the existing content entry tests

Modify tests/market-content-entry.test.ts to add:

test("boots the controller only after auth succeeds", async () => {
  const createMarketController = vi.fn(() => ({ ready: Promise.resolve() }));
  await bootContentScript({
    createMarketController,
    document,
    sendAuthMessage: vi.fn(async () => ({
      ok: true,
      type: "auth:state",
      value: { isAuthenticated: true }
    })),
    window
  });
  expect(createMarketController).toHaveBeenCalledTimes(1);
});

Run: npm test -- tests/market-content-entry.test.ts Expected: FAIL until bootContentScript accepts the new auth dependency

  • Step 3: Implement the auth gate UI

Create src/content/market/auth-gate.ts:

export function renderMarketAuthGate(document: Document): HTMLElement {
  const root = document.createElement("section");
  root.dataset.marketAuthGate = "root";
  root.innerHTML = `
    <strong>请先登录插件</strong>
    <p>打开扩展弹窗完成登录后刷新本页</p>
    <button type="button" data-market-auth-help="button">去登录</button>
  `;
  root.querySelector("[data-market-auth-help='button']")?.addEventListener("click", () => {
    window.alert("请点击浏览器工具栏中的扩展图标完成登录");
  });
  document.body.prepend(root);
  return root;
}
  • Step 4: Gate bootContentScript() before controller startup

Modify src/content/index.ts to:

  • accept an injectable sendAuthMessage
  • call background with { type: "auth:get-state" }
  • if unauthenticated, render the gate and return { ready: Promise.resolve() } or null
  • only call createMarketController() after auth succeeds

Suggested helper:

async function readAuthState(
  sendMessage: (message: unknown) => Promise<unknown>
): Promise<AuthStateValue | null> {
  const response = await sendMessage({ type: "auth:get-state" });
  return response.ok && response.type === "auth:state" ? response.value : null;
}
  • Step 5: Keep market controller auth-agnostic

Only touch src/content/market/index.ts if the existing controller assumes it always owns the toolbar root. Prefer to keep auth gating in src/content/index.ts; only add defensive guards if the current toolbar boot sequence conflicts with the auth gate DOM.

  • Step 6: Run content gating tests

Run: npm test -- tests/market-auth-gating.test.ts tests/market-content-entry.test.ts Expected: PASS

  • Step 7: Commit the auth gate slice

Run:

git add src/content/index.ts src/content/market/auth-gate.ts src/content/market/index.ts tests/market-auth-gating.test.ts tests/market-content-entry.test.ts
git commit -m "feat: gate market tools behind authentication"

Task 5: Dev Diagnostics, Build Verification, And Docs

Files:

  • Modify: src/popup/view.ts

  • Modify: src/popup/index.ts

  • Modify: src/background/auth/controller.ts

  • Modify: README.md

  • Modify: externaldocs/2026-04-21-logto-auth-design.md

  • Step 1: Write the failing popup dev panel test

Extend tests/popup-entry.test.ts with:

test("renders the dev auth panel when enabled", async () => {
  document.body.innerHTML = "<main id='app'></main>";

  await bootPopup({
    config: { enableDevAuthPanel: true },
    document,
    sendMessage: vi.fn(async () => ({
      ok: true,
      type: "auth:state",
      value: {
        accessTokenExpiresAt: 1700000000000,
        isAuthenticated: true,
        resource: "https://api.example.test",
        scopes: ["openid", "profile"],
        tokenAvailable: true,
        userInfo: { email: "dev@example.com", name: "Dev" }
      }
    }))
  });

  expect(document.body.textContent).toContain("resource");
  expect(document.body.textContent).toContain("token");
});

Run: npm test -- tests/popup-entry.test.ts Expected: FAIL because the dev panel is not rendered yet

  • Step 2: Implement minimal diagnostics

Update popup rendering to show when enableDevAuthPanel is true:

  • resource
  • scopes
  • tokenAvailable
  • accessTokenExpiresAt
  • lastError

Update background state mapping to return tokenAvailable and accessTokenExpiresAt placeholders even when the exact expiry cannot be resolved yet:

return {
  accessTokenExpiresAt: null,
  isAuthenticated: true,
  resource: config.apiResource,
  scopes: config.scopes,
  tokenAvailable: true,
  userInfo
};
  • Step 3: Run the popup suite and the full test suite

Run: npm test -- tests/popup-entry.test.ts Expected: PASS

Run: npm test Expected: PASS across existing and new tests

  • Step 4: Run the build and verify output structure

Run: npm run build Expected:

  • dist/manifest.json

  • dist/background/index.js

  • dist/content/index.js

  • dist/content/market-page-bridge.js

  • dist/popup/index.html

  • dist/popup/index.js

  • Step 5: Update docs

Update README.md with:

  • how to load the popup
  • what placeholder Logto config values must be replaced
  • what “unauthenticated” looks like on the market page

Update externaldocs/2026-04-21-logto-auth-design.md only if implementation choices diverge from the design. Also fix any current encoding artifacts in that spec while editing.

  • Step 6: Commit the verification and docs slice

Run:

git add src/popup/view.ts src/popup/index.ts src/background/auth/controller.ts README.md externaldocs/2026-04-21-logto-auth-design.md
git commit -m "docs: document logto auth flow and diagnostics"

Final Verification Checklist

  • npm test -- tests/auth-config.test.ts tests/auth-messages.test.ts
  • npm test -- tests/background-auth-controller.test.ts tests/background-index.test.ts
  • npm test -- tests/popup-entry.test.ts tests/manifest.test.ts
  • npm test -- tests/market-auth-gating.test.ts tests/market-content-entry.test.ts
  • npm test
  • npm run build
  • Load dist/ in chrome://extensions
  • Verify popup shows logged-out state before configuration is real
  • Verify market page shows auth gate when unauthenticated

Open Inputs Still Required From The User

  • Real logtoEndpoint
  • Real appId
  • Real apiResource
  • Any extra scopes beyond openid profile offline_access
  • Whether dev auth panel should be hard-disabled in production builds or controlled by config only