diff --git a/dashboard/.env.example b/dashboard/.env.example index 4fd5c05..e9bf733 100644 --- a/dashboard/.env.example +++ b/dashboard/.env.example @@ -1,10 +1,34 @@ -# Postgres connection string (direct, no pooler). -# Local dev: docker-compose.yml provides the matching credentials. -# Production (Neon): direct connection string (no "-pooler" in hostname). -# Connection pooling in prod is handled via @prisma/adapter-neon, not a separate URL. +# ─── Database ────────────────────────────────────────────────────────────────── +# Local dev: credentials from docker-compose.yml (port 5433). +# Neon (prod): https://console.neon.tech → your project → Connection string +# Use the "Pooled connection" string for DATABASE_URL. DATABASE_URL="postgresql://postgres:postgres@localhost:5433/worktrace" +# ─── Google OAuth ────────────────────────────────────────────────────────────── +# https://console.cloud.google.com → APIs & Services → Credentials +# → "+ CREATE CREDENTIALS" → "OAuth 2.0 Client ID" +# +# For the Extension auth flow (Week 2): +# Application type: Chrome Extension +# Application ID: your unpacked extension ID from chrome://extensions +# → copy the generated Client ID here +# +# For the Web Dashboard OAuth (Week 3): +# Create a SEPARATE Client ID with Application type: Web application +# Authorized redirect URIs: http://localhost:3000/api/auth/callback/google GOOGLE_CLIENT_ID="" + +# Client secret — needed in Week 3 for the web dashboard authorization code flow. +# Same credentials page as GOOGLE_CLIENT_ID (Web application type). GOOGLE_CLIENT_SECRET="" + +# ─── JWT ─────────────────────────────────────────────────────────────────────── +# Random 256-bit secret used to sign/verify our own JWTs. +# Generate: openssl rand -base64 32 JWT_SECRET="" -NEXT_PUBLIC_APP_URL="http://localhost:3000" \ No newline at end of file + +# Token lifetime — any value accepted by the `ms` library (e.g. 7d, 24h, 3600). +JWT_EXPIRES_IN="7d" + +# ─── App ─────────────────────────────────────────────────────────────────────── +NEXT_PUBLIC_APP_URL="http://localhost:3000" diff --git a/dashboard/app/api/auth/extension/route.ts b/dashboard/app/api/auth/extension/route.ts new file mode 100644 index 0000000..8a5fc5d --- /dev/null +++ b/dashboard/app/api/auth/extension/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withCors, corsPreflight } from "@/server/cors"; +import { ExtensionAuthInput } from "@/server/schemas/auth"; +import { authenticateExtensionUser } from "@/server/auth"; + +export const POST = withCors(async (req: NextRequest) => { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = ExtensionAuthInput.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.format() }, { status: 400 }); + } + + try { + const token = await authenticateExtensionUser(parsed.data.googleToken); + return NextResponse.json({ token }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Authentication failed"; + return NextResponse.json({ error: message }, { status: 401 }); + } +}); + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/server/auth.ts b/dashboard/server/auth.ts new file mode 100644 index 0000000..693e378 --- /dev/null +++ b/dashboard/server/auth.ts @@ -0,0 +1,57 @@ +import { OAuth2Client } from "google-auth-library"; +import jwt from "jsonwebtoken"; +import { prisma } from "@/server/db"; + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing required env var: ${name}`); + return v; +} + +const googleClient = new OAuth2Client(); + +async function verifyGoogleToken(idToken: string) { + const ticket = await googleClient.verifyIdToken({ + idToken, + audience: requireEnv("GOOGLE_CLIENT_ID"), + }); + const payload = ticket.getPayload(); + if (!payload?.sub || !payload?.email) { + throw new Error("Invalid token payload: missing sub or email"); + } + return { + googleId: payload.sub, + email: payload.email, + name: payload.name ?? null, + picture: payload.picture ?? null, + }; +} + +async function upsertUser(data: { + googleId: string; + email: string; + name: string | null; + picture: string | null; +}) { + return prisma.user.upsert({ + where: { googleId: data.googleId }, + update: { email: data.email, name: data.name, picture: data.picture }, + create: { googleId: data.googleId, email: data.email, + name: data.name, picture: data.picture }, + }); +} + +function issueJWT(userId: string, email: string): string { + const expiresIn = (process.env.JWT_EXPIRES_IN ?? "7d") as jwt.SignOptions["expiresIn"]; + return jwt.sign( + { sub: userId, email }, + requireEnv("JWT_SECRET"), + { expiresIn }, + ); +} + +export async function authenticateExtensionUser(googleIdToken: string): Promise { + const googleUser = await verifyGoogleToken(googleIdToken); + const user = await upsertUser(googleUser); + return issueJWT(user.id, user.email); +} diff --git a/dashboard/server/schemas/auth.ts b/dashboard/server/schemas/auth.ts new file mode 100644 index 0000000..a62489e --- /dev/null +++ b/dashboard/server/schemas/auth.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const ExtensionAuthInput = z.object({ + googleToken: z.string().min(1, "googleToken is required"), +}); +export type ExtensionAuthInput = z.infer; diff --git a/extension/.env.example b/extension/.env.example index 99cbcd5..867e941 100644 --- a/extension/.env.example +++ b/extension/.env.example @@ -1 +1,12 @@ -VITE_API_BASE_URL=http://localhost:3000 +# ─── Dashboard URL ───────────────────────────────────────────────────────────── +# Local dev: http://localhost:3000 +# Production: your Vercel deployment URL (e.g. https://worktrace-ecru.vercel.app) +VITE_DASHBOARD_URL="http://localhost:3000" + +# ─── Google OAuth ────────────────────────────────────────────────────────────── +# https://console.cloud.google.com → APIs & Services → Credentials +# → "+ CREATE CREDENTIALS" → "OAuth 2.0 Client ID" +# Application type: Chrome Extension +# Application ID: your unpacked extension ID from chrome://extensions +# → copy the generated Client ID here (same value as GOOGLE_CLIENT_ID in dashboard/.env) +VITE_GOOGLE_CLIENT_ID="" diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 3c1839f..aef3cc0 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -1,5 +1,155 @@ -console.log("[worktrace] service worker booted"); +import type { AuthMessage, AuthResponse, StoredAuth } from "../types/auth"; + +const DASHBOARD_URL = import.meta.env.VITE_DASHBOARD_URL as string; +const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID as string; +const EXPIRY_BUFFER_MS = 60_000; + +// ─── Storage ─────────────────────────────────────────────────────────────────── + +async function getStoredAuth(): Promise { + const r = await chrome.storage.local.get(["jwt", "jwtExpiresAt"]); + if (!r["jwt"] || !r["jwtExpiresAt"]) return null; + return { jwt: r["jwt"] as string, jwtExpiresAt: r["jwtExpiresAt"] as number }; +} + +async function storeAuth(jwt: string, expiresAt: number): Promise { + await chrome.storage.local.set({ jwt, jwtExpiresAt: expiresAt }); +} + +async function clearAuth(): Promise { + await chrome.storage.local.remove(["jwt", "jwtExpiresAt"]); +} + +function isExpired(expiresAt: number): boolean { + return Date.now() >= expiresAt - EXPIRY_BUFFER_MS; +} + +// ─── Google OAuth ────────────────────────────────────────────────────────────── + +async function launchGoogleOAuth(): Promise { + const redirectUrl = chrome.identity.getRedirectURL("auth"); + const nonce = crypto.randomUUID(); + + const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + url.searchParams.set("client_id", GOOGLE_CLIENT_ID); + url.searchParams.set("response_type", "id_token"); + url.searchParams.set("redirect_uri", redirectUrl); + url.searchParams.set("scope", "openid email profile"); + url.searchParams.set("nonce", nonce); + url.searchParams.set("prompt", "select_account"); + + const responseUrl = await chrome.identity.launchWebAuthFlow({ + url: url.toString(), + interactive: true, + }); + + if (!responseUrl) throw new Error("OAuth flow cancelled"); + + const fragment = new URL(responseUrl).hash.slice(1); + const idToken = new URLSearchParams(fragment).get("id_token"); + if (!idToken) throw new Error("No id_token in OAuth response"); + return idToken; +} + +// ─── JWT exchange ────────────────────────────────────────────────────────────── + +async function exchangeForJWT(googleIdToken: string): Promise { + const res = await fetch(`${DASHBOARD_URL}/api/auth/extension`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ googleToken: googleIdToken }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})) as Record; + throw new Error(`JWT exchange failed (${res.status}): ${JSON.stringify(body)}`); + } + + const { token } = await res.json() as { token: string }; + const payloadB64 = token.split(".")[1] ?? ""; + const { exp } = JSON.parse(atob(payloadB64)) as { exp: number }; + return { jwt: token, jwtExpiresAt: exp * 1000 }; +} + +// ─── Core ────────────────────────────────────────────────────────────────────── + +async function ensureAuthenticated(): Promise { + const stored = await getStoredAuth(); + if (stored && !isExpired(stored.jwtExpiresAt)) return stored.jwt; + + const googleIdToken = await launchGoogleOAuth(); + const auth = await exchangeForJWT(googleIdToken); + await storeAuth(auth.jwt, auth.jwtExpiresAt); + return auth.jwt; +} + +// Використовується в AC 5 для всіх API-запитів +export async function apiFetch( + path: string, + init: RequestInit = {}, +): Promise { + const token = await ensureAuthenticated(); + return fetch(`${DASHBOARD_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init.headers as Record | undefined), + Authorization: `Bearer ${token}`, + }, + }); +} + +// ─── Message listener ────────────────────────────────────────────────────────── + +chrome.runtime.onMessage.addListener( + (message: AuthMessage, _sender, sendResponse: (r: AuthResponse) => void) => { + if (message.type === "AUTH_LOGIN") { + ensureAuthenticated() + .then((jwt) => sendResponse({ success: true, jwt })) + .catch((err: unknown) => + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }), + ); + return true; + } + + if (message.type === "AUTH_LOGOUT") { + clearAuth() + .then(() => sendResponse({ success: true })) + .catch((err: unknown) => + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }), + ); + return true; + } + + if (message.type === "AUTH_GET_STATUS") { + getStoredAuth() + .then((stored) => + sendResponse({ + success: true, + isAuthenticated: stored !== null && !isExpired(stored.jwtExpiresAt), + }), + ) + .catch((err: unknown) => + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }), + ); + return true; + } + + return false; + }, +); + +// ─── Lifecycle ───────────────────────────────────────────────────────────────── chrome.runtime.onInstalled.addListener((details) => { - console.log("[worktrace] installed", details.reason); + console.log("[worktrace] service worker installed:", details.reason); }); diff --git a/extension/src/manifest.ts b/extension/src/manifest.ts index 27a41b7..6330a4e 100644 --- a/extension/src/manifest.ts +++ b/extension/src/manifest.ts @@ -27,7 +27,7 @@ export default defineManifest({ run_at: "document_idle", }, ], - permissions: ["storage", "activeTab", "tabs"], + permissions: ["storage", "activeTab", "tabs", "identity"], host_permissions: [ "http://localhost:3000/*", "https://worktrace-ecru.vercel.app/*", diff --git a/extension/src/popup/index.html b/extension/src/popup/index.html index caaa154..068fed1 100644 --- a/extension/src/popup/index.html +++ b/extension/src/popup/index.html @@ -9,7 +9,11 @@

WorkTrace

-

Coming soon.

+
+

Checking...

+ + +
diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 701c59c..b5d394d 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -1 +1,31 @@ -console.log("[worktrace] popup loaded"); +import type { AuthMessage, AuthResponse } from "../types/auth"; + +function sendMessage(message: AuthMessage): Promise { + return new Promise((resolve) => { + chrome.runtime.sendMessage(message, resolve); + }); +} + +const statusEl = document.getElementById("auth-status") as HTMLParagraphElement; +const btnLogin = document.getElementById("btn-login") as HTMLButtonElement; +const btnLogout = document.getElementById("btn-logout") as HTMLButtonElement; + +// Check auth status when popup opens +sendMessage({ type: "AUTH_GET_STATUS" }).then((res) => { + if (res.success && "isAuthenticated" in res) { + statusEl.textContent = res.isAuthenticated ? "✅ Signed in" : "❌ Not signed in"; + } +}); + +btnLogin.addEventListener("click", async () => { + btnLogin.disabled = true; + statusEl.textContent = "Signing in..."; + const res = await sendMessage({ type: "AUTH_LOGIN" }); + statusEl.textContent = res.success ? "✅ Signed in" : `❌ ${!res.success ? res.error : ""}`; + btnLogin.disabled = false; +}); + +btnLogout.addEventListener("click", async () => { + await sendMessage({ type: "AUTH_LOGOUT" }); + statusEl.textContent = "❌ Not signed in"; +}); diff --git a/extension/src/types/auth.ts b/extension/src/types/auth.ts new file mode 100644 index 0000000..259bcf5 --- /dev/null +++ b/extension/src/types/auth.ts @@ -0,0 +1,15 @@ +export interface StoredAuth { + jwt: string; + jwtExpiresAt: number; // Unix ms +} + +export type AuthMessage = + | { type: "AUTH_LOGIN" } + | { type: "AUTH_LOGOUT" } + | { type: "AUTH_GET_STATUS" }; + +export type AuthResponse = + | { success: true; jwt: string } + | { success: true; isAuthenticated: boolean } + | { success: true } + | { success: false; error: string }; diff --git a/plans/week2/AC1-auth.md b/plans/week2/AC1-auth.md new file mode 100644 index 0000000..25c83c3 --- /dev/null +++ b/plans/week2/AC1-auth.md @@ -0,0 +1,107 @@ +# Plan: Week 2 AC 1 — Auth Extension → Dashboard + +**Branch:** `feature/extension-auth-google-oauth` +**Closes:** Week 2 AC 1 ([docs/Task.md L69](../../docs/Task.md#L69)) +**Status:** plan, awaiting review + +--- + +## Scope + +Full authentication flow між Chrome Extension і Next.js Dashboard: + +1. `chrome.identity.launchWebAuthFlow` → Google ID token +2. `POST /api/auth/extension` → dashboard верифікує token, повертає власний JWT +3. JWT зберігається в `chrome.storage.local` (service worker only) +4. Всі наступні API-запити йдуть через `apiFetch()` у service worker з `Authorization: Bearer ` + +--- + +## Commits (ordered) + +``` +chore: update .env.example files with auth vars ✅ done +feat(dashboard): add auth schema, server module and /api/auth/extension route +feat(extension): implement google oauth flow in service worker +feat(extension): add auth status and login/logout to popup +``` + +--- + +## Files + +| File | Action | Commit | +|---|---|---| +| `dashboard/.env.example` | updated | ✅ done | +| `extension/.env.example` | updated | ✅ done | +| `dashboard/server/schemas/auth.ts` | **new** | commit 2 | +| `dashboard/server/auth.ts` | **new** | commit 2 | +| `dashboard/app/api/auth/extension/route.ts` | **new** | commit 2 | +| `extension/src/types/auth.ts` | **new** | commit 3 | +| `extension/src/manifest.ts` | modify — add `"identity"` | commit 3 | +| `extension/src/background/index.ts` | **rewrite** | commit 3 | +| `extension/src/popup/index.html` | modify | commit 4 | +| `extension/src/popup/popup.ts` | modify | commit 4 | + +--- + +## Design decisions + +### 1. Google OAuth: `launchWebAuthFlow` з `response_type=id_token` + +`chrome.identity.getAuthToken` повертає access token — не верифікується через `google-auth-library.verifyIdToken()` без додаткового round-trip до Google. + +`launchWebAuthFlow` з OIDC params дає справжній JWT ID token, що верифікується локально через публічні ключі Google. Redirect URL: `chrome.identity.getRedirectURL("auth")` → `https://.chromiumapp.org/auth`. ID token повертається у hash fragment. + +``` +const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); +// response_type=id_token, scope=openid email profile, nonce=crypto.randomUUID() +``` + +### 2. JWT зберігається тільки в service worker + +Popup і content script ніколи не бачать JWT — тільки надсилають повідомлення типу `AUTH_LOGIN / AUTH_LOGOUT / AUTH_GET_STATUS` до background і отримують статус. + +### 3. Token expiry + auto-refresh в `ensureAuthenticated()` + +При кожному `apiFetch` перевіряємо `jwtExpiresAt - 60s < now()`. Якщо прострочений — повний OAuth flow. Без окремого background timer (service worker зупиняється між запитами в MV3). + +### 4. Dashboard: `server/auth.ts` — фасад з трьох кроків + +``` +verifyGoogleToken() → upsertUser() → issueJWT() +``` + +Публічний API — один метод `authenticateExtensionUser(googleIdToken): Promise`. Route handler тільки парсить, валідує і викликає його. + +### 5. `GOOGLE_CLIENT_SECRET` — не потрібен для AC 1 + +`OAuth2Client.verifyIdToken()` верифікує підпис через публічні JWK ключі Google. Secret знадобиться у Week 3 для authorization code flow в web dashboard. + +--- + +## Verification checklist + +### Dashboard +- [ ] `cd dashboard && npx tsc --noEmit` — без помилок +- [ ] `POST /api/auth/extension` без body → 400 +- [ ] `POST /api/auth/extension` з невалідним токеном → 401 +- [ ] `POST /api/auth/extension` з валідним Google ID token → 200 `{ token: "eyJ..." }` +- [ ] User з'явився/оновився в БД +- [ ] `OPTIONS /api/auth/extension` з extension origin → 204 + CORS headers + +### Extension +- [ ] `cd extension && npm run build` — без помилок +- [ ] Розширення завантажується в `chrome://extensions` без помилок +- [ ] Клік Login → Google OAuth popup → авторизація → статус змінюється +- [ ] `chrome.storage.local.get(["jwt"])` у DevTools service worker → JWT присутній +- [ ] Повторний клік Login (JWT свіжий) → не відкриває OAuth popup +- [ ] Клік Logout → JWT видалено зі storage + +--- + +## Out of scope + +- JWT верифікація у Route Handlers (окрім `/api/auth/extension`) — Week 3 middleware +- Refresh token / sliding session — `7d` JWT достатньо для MVP +- Error toast у popup — Week 3 бонус AC 5