Skip to content

Extension login flow — PR2: scoped PAT auth for stock writes (E3–E16) #3784

Description

@ryota-murakami

Context

Extension は今 nsx にログインしていないとページ登録に失敗する(401)。原因は popup の
保存/重複チェックリクエストに Authorization ヘッダもトークンも無いこと(server/auth.ts
isAuthorized が 401)。解決策 = Option D(スコープ付き PAT): オーナーが Web 設定画面で
トークンを1回発行 → Extension に貼付 → 保存時のみ Authorization: Bearer nsx_pat_<token>
を送る。Cookie ログインの仕組みには一切触らない。PR1(テスト配線・マイグレーション)
マージ後に着手。

Current State (verified 2026-05-28)

  • browser-extension/src/entrypoints/popup/App.tsx の保存/重複チェックが認証ヘッダ無し
  • server/routes/stock.ts:105 POST /push_stock:176 GET /stock/existsisAuthorized ゲート
  • server/lib/authSession.ts:48-50 hashRefreshToken = createHash('sha256').update(token).digest('hex')(PAT が再利用すべき方式)
  • prisma/schema.prisma:84-96 RefreshToken モデル(PAT モデルの雛形)
  • E3 の実バグ(検証済み): App.tsx:62,139import.meta.env.VITE_API_ENDPOINT を読むが、
    .env.development/.env.production が定義するのは別キー VITE_API_URL。よって
    VITE_API_ENDPOINT は常に undefined → 常に DEFAULT_API_ENDPOINThttp://localhost:4000/api/,
    constants.ts:2)にフォールバック。本番ビルドの Extension が localhost を叩く
    加えて env 値が .../api/push_stock フルパス。buildPushStockApiUrl は base を期待し
    自前で /api/push_stock を付与(重複ガード有り)するため、フルパスを渡すと二重付与で 404。

Proposed Change

Server

  • E7/E12 PersonalAccessToken モデル + 逆リレーション(下記 Implementation Details の Prisma ブロック)
  • mint / list / revoke エンドポイント(Web cookie セッション = isAuthorized のまま。下記 API 仕様)
  • E1 専用ミドルウェア authenticateStockRequest: cookie 認証 → 失敗で PAT 認証、
    PAT 失敗時は絶対に clearAuthCookies を呼ばない(confused-deputy 防止)
  • E4 PAT lookup で SESSION_USER_SELECT を使い req.authenticatedUser を全行ハイドレート(部分 {id} 不可)
  • E14 原子的検証: findFirst({ where: { tokenHash, revokedAt: null, OR: [{expiresAt: null}, {expiresAt: {gt: now}}] } }) 単一クエリ
  • E6 不正/欠落 Authorization → 401(500 でない)
  • E8 push_stock のミドルウェアを isAuthorizedauthenticateStockRequest に差替(validateBody は2番目のまま)
  • E9 PAT を /push_stock + /stock/exists のみで受理(stocklist/delete は cookie のまま)
  • E5 生トークンを絶対にログしない(App.tsx:180 のサニタイズ + Logger.ts:12 の redaction キー拡張)

Web (src/pages/Dashboard/Setting/)

  • E13 「Extension Token」セクション(hand-rolled Button/Input + Tailwind4 + lucide、shadcn/radix 不使用)。
    Generate → show-once 生トークンパネル + copy-to-clipboard(SnackBar 不使用: 大文字化・5s 自動消滅・閉じるボタン未配線)、
    一覧は masked nsx_pat_…<last4>、各行に revoke ボタン

Extension (browser-extension/)

  • E3 env キー不一致を修正(下記 Implementation Details)、NSX-80 を unskip
  • トークン貼付 UI(chrome.storage.local)、connected 状態、401 → reconnect プロンプト
  • E11 Playwright global setup で server:start 前にテスト DB マイグレーション
  • E15(P3)host_permissions<all_urls> から NSX API origin + active-tab に縮小
  • E16(P3)実機ビルドで PAT 保存が 201/401 に到達(CORS エラーでない)を確認

Implementation Details (concrete — leaves zero design decisions)

PAT Prisma model (prisma/schema.prisma, mirrors RefreshToken:84-96)

model PersonalAccessToken {
  id          Int       @id @default(autoincrement())
  tokenHash   String    @unique @db.Char(64)   // sha256 hex of "nsx_pat_<raw>"
  tokenSuffix String    @db.Char(4)            // last 4 chars of raw token, for masked list (hash can't reveal it)
  name        String    @db.VarChar(255)       // human label, e.g. "Chrome extension"
  userId      Int
  lastUsedAt  DateTime?
  expiresAt   DateTime?                        // null = non-expiring (default for this single-scope PAT)
  revokedAt   DateTime?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@map("personal_access_tokens")
}

そして UserpersonalAccessTokens PersonalAccessToken[]refreshTokens の隣へ追加(E12, Prisma 検証に必須)。

API (cookie-session gated via existing isAuthorized; mounted under /api)

Method Path Req body Res
POST /api/personal_access_token (mint) { "name": string } 201 { id, name, token: "nsx_pat_<64hex>", tokenSuffix, createdAt }tokenこの応答1回のみ
GET /api/personal_access_token/list 200 { tokens: [{ id, name, tokenSuffix, createdAt, lastUsedAt, revokedAt }] }token/tokenHash絶対に含めない
DELETE /api/personal_access_token/:id (revoke) 200 { id, revokedAt }revokedAt をセット)
  • トークン生成: nsx_pat_ + randomBytes(32).toString('hex')(64 hex, 256-bit)。保存は hashToken(raw) の sha256 hex のみ。tokenSuffix = raw 末尾4文字。
  • mint はレート制限(既存ミドルウェアがあれば再利用、無ければ簡易に 10 req / hour / user)。
  • hashRefreshTokenauthSession.ts:48-50)を汎用 hashToken にリネームし mint/検証で共用(DRY、2つ目のハッシュ関数を作らない)。

E3 env fix (verified root cause above)

  1. キー名を揃える: env の VITE_API_URLVITE_API_ENDPOINT にリネーム(または App.tsx 側を VITE_API_URL 読み取りに変更)。src/env.d.ts:4 の型も同期。
  2. 値は origin/base のみにする(buildPushStockApiUrl/api/push_stock を付与する):
    • .env.development: VITE_API_ENDPOINT=http://localhost:4000
    • .env.production: VITE_API_ENDPOINT=https://nsx.malloc.tokyo
  3. WXT が本番ビルドで .env.production を読むこと(build mode 配線)を確認し、NSX-80 を unskip。

E11 Playwright test-DB migration

browser-extension/playwright.config.tsglobalSetup で、pnpm server:start
テスト用 DATABASE_URL に対して pnpm prisma migrate deploy を実行し、personal_access_tokens
テーブルを作る。

Acceptance Criteria

  1. Web 設定で生成したトークンを Extension に貼付 → ページ保存が 201
  2. 無効/revoke 済 PAT → 401、かつ Set-Cookie クリアを発行しない(U5 回帰ガード)
  3. Authorization ヘッダ無しのリクエストは従来通り cookie 認証で動作(U7 回帰ガード)
  4. 生トークンは mint 応答1回のみ表示、list/ログ/DOM に生値が出ない(list は tokenSuffix のみ)
  5. /stock/exists も PAT を受理(popup の on-open 重複チェックが connected に到達)
  6. mint の生成・list の非開示・revoke の 401 化が単体テストで緑(U1–U8)
  7. prisma validate 通過、tsc 通過、pnpm lint warning 0
  8. 既存 cookie ログイン web アプリに回帰なし(既存テスト全緑)
  9. E3: 本番ビルドの Extension が https://nsx.malloc.tokyo/api/push_stock を叩く(localhost でない)、NSX-80 緑
  10. E15(P3): host_permissions 縮小後も popup が active-tab メタデータを読め、保存が 201
  11. E16(P3): ビルド済み(dev でない)Extension の保存が 201/401 を返す(CORS ネットワークエラーでない)。CORS エラーなら ext origin を server CORS 許可リストに追加

Testing Plan

Layer What Count
Unit(node) mint/list/revoke, PAT accept/reject, U5/U6/U7/U8, lastUsedAt +8
Component(web) show-once, masked list, 5xx で panel 維持 +3
Component(ext) paste UI, connected, 401→reconnect, Bearer 添付 +4
E2E 貼付→保存成功、revoke→reconnect +2

詳細: eng-review-test-plan-20260528-104950.md(U1–U8/W1–W3/X1–X4/E2E1–E2E2、DAMP・AAA・behavior 命名)

Rollback Plan

PR revert で PAT コード除去。マイグレーションは additive(新テーブルのみ)なので
既存データ影響なし。personal_access_tokens テーブルは drop 可能。

Effort Estimate

schema+migration ~2h + server auth/routes ~4h + web UI ~3h + ext ~3h + tests ~4h = 約1.5〜2日

Files Reference

File Change
prisma/schema.prisma:10-26 User.personalAccessTokens[] 追加(E12)
prisma/schema.prisma:84-96 付近 PersonalAccessToken モデル追加(E7)
server/lib/authSession.ts:48-50 hashRefreshToken→汎用 hashToken 改名・再利用
server/lib/authSession.ts (新規分岐) authenticateStockRequest(E1/E4/E6/E14)
server/routes/ (新規 personalAccessToken.ts) mint/list/revoke。server/api.tsrouter.use
server/routes/stock.ts:105,176 ミドルウェア差替(E8)、PAT 受理(E9)
server/lib/Logger.ts:12 redaction キー拡張(E5: rawToken/pat/personalAccessToken/uppercase Authorization)
src/pages/Dashboard/Setting/ Extension Token UI(E13)
browser-extension/.env.development, .env.production VITE_API_URLVITE_API_ENDPOINT、値を origin に(E3)
browser-extension/src/env.d.ts:4 型キー同期(E3)
browser-extension/src/entrypoints/popup/App.tsx:62,139,180 Bearer 添付・貼付 UI・raw token 非ログ
browser-extension/wxt.config.ts host_permissions 縮小(E15, P3)
browser-extension/playwright.config.ts テスト DB マイグレーション(E11)

Out of Scope

  • in-extension password ログイン、/api/login 変更、SameSite 緩和、chrome.cookies 権限
  • OAuth/SSO、マルチユーザー、PAT 自動ローテーション/複数スコープ

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions