854 lines
24 KiB
Markdown

# 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:
```json
{
"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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```bash
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:
```ts
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:
```ts
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`:
```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:
```ts
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`:
```ts
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:
```ts
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:
```bash
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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`:
```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:
```json
{
"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:
```bash
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`:
```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:
```ts
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`:
```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:
```ts
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:
```bash
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:
```ts
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:
```ts
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:
```bash
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