- 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 localStorageKey choices
| Aspect | Choice | Rationale |
|---|---|---|
| OAuth library | Native fetch + manual flow | ~50 строк, нет depend overhead |
| JWT library | jose | Standard для TS, работает в Workers runtime |
| JWT algorithm | HS256 (256-bit secret) | Master spec §0.5 |
| Token storage | localStorage | Cross-origin (pages.dev ↔ workers.dev), httpOnly cookie требует shared parent domain (Phase 10/11 с custom domain) |
| State CSRF protection | Random nonce в KV TTL 10min | Standard OAuth 2.0 §10.12 |
| Revocation | KV revoked:{jti} TTL=remaining_exp | Master spec §I.2.1 (KV pattern) |
| Token expiry | 7 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 temporarilyproject_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) — тогда:
- Migrate apps/web на next-on-pages
- Add Auth.js routes
- 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, использует те же
usertable) - ADR-0001 — static export decision
- ADR-0005 — dedicated Worker decision