ADRs
ADR 0006 — Backend-driven GitHub OAuth, не Auth.js на Pages
  • Date: 2026-05-21
  • Status: Accepted
  • Phase: 9
  • Supersedes: конкретику §III.2.6 master spec ("Frontend (Pages): Auth.js v5 ...")

Context

Master spec §III.2.6 предполагает Auth.js v5 живёт на Cloudflare Pages с Edge Runtime, обрабатывает OAuth callback через Next.js API routes (app/api/auth/[...nextauth]/route.ts), выдаёт JWT который Workers backend верифицирует.

Однако:

  • ADR-0001 зафиксировал static export для apps/web (Phase 0-8 на этом устойчиво деплоятся)
  • API routes Next.js не работают со static export — Auth.js технически невозможен в current setup
  • Master spec §VI #1 явно указывает Auth.js Edge как week-1 prototyping validation с fallback на Lucia

Два варианта решения:

A. Migrate Pages → @cloudflare/next-on-pages edge adapter → restore §III.2.6 conformance.

  • Pros: Auth.js out-of-the-box, conformance
  • Cons: 1-2 дня adapter setup, bundle overhead (~50KB+), риск регрессии Phase 0-8 features, adapter edge cases (Node-incompatible APIs)

B. Backend-driven OAuth в apps/api (current ADR).

  • Pros: static export remains, foundation Phase 0-8 не трогается, Workers Paid уже живёт, Bearer JWT в Authorization header — это и так planned в §III.2.6 для backend middleware
  • Cons: deviation от Auth.js — custom OAuth handler ~50-100 строк Hono

Decision

B — backend-driven OAuth в apps/api.

OAuth web flow и JWT issuance перемещаются с Pages на Workers. Frontend остаётся pure SPA с Bearer JWT в Authorization header.

Flow

1. Frontend: пользователь жмёт "Sign in with GitHub"
2. Frontend → window.location = `https://arno-api.../auth/github/login?return_to=https://arno-ijr.pages.dev/app`
3. Worker /auth/github/login:
   - Генерирует state nonce (random, stored в KV TTL 10min)
   - Redirect 302 → https://github.com/login/oauth/authorize?client_id=...&state=...&redirect_uri=...&scope=read:user user:email
4. GitHub: пользователь авторизует → 302 → /auth/github/callback?code=...&state=...
5. Worker /auth/github/callback:
   - Verify state nonce (consume from KV)
   - POST github.com/login/oauth/access_token с code → access_token
   - GET api.github.com/user → user data (id, login, name, avatar_url)
   - GET api.github.com/user/emails → primary email
   - UPSERT user table (по provider+provider_user_id)
   - Sign HS256 JWT { sub: user_id, jti: uuid, exp: now+7d }
   - Redirect 302 → return_to URL + #token=<jwt>
6. Frontend (landing/app): parses URL fragment → stores JWT в localStorage → reload без fragment
7. Все API calls: fetch(..., { headers: { Authorization: `Bearer ${jwt}` } })
8. Logout: POST /auth/logout → backend stores jti в KV revocation list TTL=remaining → frontend clears localStorage

Key choices

AspectChoiceRationale
OAuth libraryNative fetch + manual flow~50 строк, нет depend overhead
JWT libraryjoseStandard для TS, работает в Workers runtime
JWT algorithmHS256 (256-bit secret)Master spec §0.5
Token storagelocalStorageCross-origin (pages.dev ↔ workers.dev), httpOnly cookie требует shared parent domain (Phase 10/11 с custom domain)
State CSRF protectionRandom nonce в KV TTL 10minStandard OAuth 2.0 §10.12
RevocationKV revoked:{jti} TTL=remaining_expMaster spec §I.2.1 (KV pattern)
Token expiry7 days (single-use access — short, no refresh)Phase 9 simple; access+refresh split — парковка per §V

XSS surface (localStorage trade-off)

localStorage уязвим к XSS — если злоумышленник может вставить JS, может выкрасть JWT. Mitigations:

  • React по умолчанию escapes user content
  • Strict CSP в Phase 16 (master spec §I.4 a11y note и Phase 16 launch)
  • Preview iframe sandbox = "allow-scripts" without same-origin — bundle не имеет доступа к parent storage
  • Phase 10/11 с custom domain → httpOnly cookie option

DB schema additions

Phase 9 миграция:

CREATE TABLE "user" (
  id TEXT PRIMARY KEY,                       -- uuid
  provider TEXT NOT NULL,                    -- 'github' (post-MVP другие)
  provider_user_id TEXT NOT NULL,            -- GitHub user.id
  login TEXT NOT NULL,                       -- GitHub username
  name TEXT,
  email TEXT,
  avatar_url TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(provider, provider_user_id)
);
 
ALTER TABLE project ADD COLUMN owner_id TEXT REFERENCES "user"(id) ON DELETE CASCADE;
-- old "local" project остаётся orphan (owner_id NULL), Phase 9 migration removes its referential constraint by leaving FK nullable temporarily

project_member (multi-user per project) — Phase 11+ (master spec §I.2.1). Phase 9 один-owner-один-project.

Migration к Auth.js потом?

Если в Phase 16 захочется SSR-protected routes (например личный кабинет с server-rendered data) — тогда:

  1. Migrate apps/web на next-on-pages
  2. Add Auth.js routes
  3. Auth.js использует existing JWT format → backwards compat

Текущий ADR не блокирует этот переход.

Cross-references

  • Master spec §III.2.6 — original Auth.js Pages design
  • Master spec §VI #1 — Lucia fallback указан
  • Master spec §III.2.7 — CLI Device Flow (Phase 13 add-on, использует те же user table)
  • ADR-0001 — static export decision
  • ADR-0005 — dedicated Worker decision