From 4a7009989c91b29733ca965c57cbac7de1bea467 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 24 Mar 2026 22:01:51 +0500 Subject: [PATCH] feat(auth): replace email/password registration with OAuth2 (Google + GitHub) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New /auth/[provider] route: generates state cookie, redirects to provider - New /auth/[provider]/callback: exchanges code, fetches profile, auto-creates or links account, sets auth cookie - pocketbase.ts: add oauth_provider/oauth_id to User; new getUserByOAuth(), createOAuthUser(), linkOAuthToUser() helpers; loginUser() drops email_verified gate - pb-init-v3.sh: add oauth_provider + oauth_id fields (schema + migration) - docker-compose.yml: GOOGLE/GITHUB client ID/secret env vars (replaces SMTP vars) - Login page: two OAuth buttons (Google, GitHub) — register form removed - /verify-email route and email.ts removed (provider handles email verification) - /api/auth/register returns 410 (OAuth-only from now on) --- docker-compose.yml | 12 +- scripts/pb-init-v3.sh | 6 +- ui/src/lib/server/email.ts | 195 -------------- ui/src/lib/server/pocketbase.ts | 79 +++++- ui/src/routes/+layout.server.ts | 6 +- ui/src/routes/api/auth/register/+server.ts | 64 +---- ui/src/routes/auth/[provider]/+server.ts | 79 ++++++ .../auth/[provider]/callback/+server.ts | 246 ++++++++++++++++++ ui/src/routes/login/+page.server.ts | 140 +--------- ui/src/routes/login/+page.svelte | 225 +++++----------- ui/src/routes/verify-email/+page.server.ts | 72 ----- ui/src/routes/verify-email/+page.svelte | 21 -- 12 files changed, 495 insertions(+), 650 deletions(-) delete mode 100644 ui/src/lib/server/email.ts create mode 100644 ui/src/routes/auth/[provider]/+server.ts create mode 100644 ui/src/routes/auth/[provider]/callback/+server.ts delete mode 100644 ui/src/routes/verify-email/+page.server.ts delete mode 100644 ui/src/routes/verify-email/+page.svelte diff --git a/docker-compose.yml b/docker-compose.yml index 1e232ee..36c8b8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -267,13 +267,11 @@ services: PUBLIC_UMAMI_SCRIPT_URL: "${PUBLIC_UMAMI_SCRIPT_URL}" # GlitchTip client + server-side error tracking PUBLIC_GLITCHTIP_DSN: "${PUBLIC_GLITCHTIP_DSN}" - # Email verification (Resend SMTP — shared with Fider/GlitchTip) - SMTP_HOST: "${FIDER_SMTP_HOST}" - SMTP_PORT: "${FIDER_SMTP_PORT}" - SMTP_USER: "${FIDER_SMTP_USER}" - SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}" - SMTP_FROM: "noreply@libnovel.cc" - APP_URL: "${ORIGIN}" + # OAuth2 providers + GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}" + GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}" + GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID}" + GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET}" healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] interval: 15s diff --git a/scripts/pb-init-v3.sh b/scripts/pb-init-v3.sh index 29aa8d8..658a499 100755 --- a/scripts/pb-init-v3.sh +++ b/scripts/pb-init-v3.sh @@ -185,7 +185,9 @@ create "app_users" '{ {"name":"email", "type":"text"}, {"name":"email_verified", "type":"bool"}, {"name":"verification_token", "type":"text"}, - {"name":"verification_token_exp","type":"text"} + {"name":"verification_token_exp","type":"text"}, + {"name":"oauth_provider", "type":"text"}, + {"name":"oauth_id", "type":"text"} ]}' create "user_sessions" '{ @@ -254,5 +256,7 @@ add_field "app_users" "email" "text" add_field "app_users" "email_verified" "bool" add_field "app_users" "verification_token" "text" add_field "app_users" "verification_token_exp" "text" +add_field "app_users" "oauth_provider" "text" +add_field "app_users" "oauth_id" "text" log "done" diff --git a/ui/src/lib/server/email.ts b/ui/src/lib/server/email.ts deleted file mode 100644 index 6f5d761..0000000 --- a/ui/src/lib/server/email.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Minimal SMTP mailer for email verification. - * - * Uses Node's built-in `tls` module to connect to smtp.resend.com:465 - * (implicit TLS / SMTPS) — no external dependencies required. - * - * Env vars (injected by docker-compose via Doppler): - * SMTP_HOST smtp.resend.com - * SMTP_PORT 465 - * SMTP_USER resend - * SMTP_PASSWORD re_... - * SMTP_FROM noreply@libnovel.cc - * APP_URL https://libnovel.cc (used to build verification links) - */ - -import { env } from '$env/dynamic/private'; -import { log } from '$lib/server/logger'; -import * as tls from 'node:tls'; - -const SMTP_HOST = env.SMTP_HOST ?? 'smtp.resend.com'; -const SMTP_PORT = parseInt(env.SMTP_PORT ?? '465', 10); -const SMTP_USER = env.SMTP_USER ?? ''; -const SMTP_PASSWORD = env.SMTP_PASSWORD ?? ''; -const SMTP_FROM = env.SMTP_FROM ?? 'noreply@libnovel.cc'; -export const APP_URL = (env.APP_URL ?? 'https://libnovel.cc').replace(/\/$/, ''); - -// ─── Low-level SMTP over implicit TLS ──────────────────────────────────────── - -function smtpEncode(s: string): string { - return Buffer.from(s).toString('base64'); -} - -/** - * Send a raw email via SMTP over implicit TLS (port 465). - * Returns true on success, throws on failure. - */ -async function sendSmtp(opts: { - to: string; - subject: string; - html: string; - text: string; -}): Promise { - return new Promise((resolve, reject) => { - const socket = tls.connect( - { host: SMTP_HOST, port: SMTP_PORT, rejectUnauthorized: true }, - () => { - // TLS handshake complete — SMTP conversation begins - } - ); - - socket.setEncoding('utf8'); - socket.setTimeout(15_000); - socket.on('timeout', () => { - socket.destroy(new Error('SMTP connection timed out')); - }); - - let buf = ''; - let step = 0; - - const send = (cmd: string) => socket.write(cmd + '\r\n'); - - const boundary = `----=_Part_${Date.now()}`; - const multipart = [ - `--${boundary}`, - 'Content-Type: text/plain; charset=UTF-8', - '', - opts.text, - `--${boundary}`, - 'Content-Type: text/html; charset=UTF-8', - '', - opts.html, - `--${boundary}--` - ].join('\r\n'); - - const message = [ - `From: LibNovel <${SMTP_FROM}>`, - `To: ${opts.to}`, - `Subject: ${opts.subject}`, - 'MIME-Version: 1.0', - `Content-Type: multipart/alternative; boundary="${boundary}"`, - '', - multipart - ].join('\r\n'); - - socket.on('data', (chunk: string) => { - buf += chunk; - // Process complete lines - const lines = buf.split('\r\n'); - buf = lines.pop() ?? ''; - - for (const line of lines) { - if (!line) continue; - const code = parseInt(line.slice(0, 3), 10); - // Only act on the final response line (no continuation dash) - if (line[3] === '-') continue; - - if (code >= 400) { - socket.destroy(new Error(`SMTP error: ${line}`)); - return; - } - - switch (step) { - case 0: // 220 banner - send(`EHLO libnovel.cc`); - step++; - break; - case 1: // 250 EHLO - send('AUTH LOGIN'); - step++; - break; - case 2: // 334 Username prompt - send(smtpEncode(SMTP_USER)); - step++; - break; - case 3: // 334 Password prompt - send(smtpEncode(SMTP_PASSWORD)); - step++; - break; - case 4: // 235 Auth success - send(`MAIL FROM:<${SMTP_FROM}>`); - step++; - break; - case 5: // 250 MAIL FROM ok - send(`RCPT TO:<${opts.to}>`); - step++; - break; - case 6: // 250 RCPT TO ok - send('DATA'); - step++; - break; - case 7: // 354 Start data - send(message + '\r\n.'); - step++; - break; - case 8: // 250 Message accepted - send('QUIT'); - step++; - break; - case 9: // 221 Bye - socket.destroy(); - resolve(); - break; - } - } - }); - - socket.on('error', (err) => reject(err)); - socket.on('close', () => { - if (step < 9) reject(new Error('SMTP connection closed unexpectedly')); - }); - }); -} - -// ─── Email templates ────────────────────────────────────────────────────────── - -export async function sendVerificationEmail(to: string, token: string): Promise { - const link = `${APP_URL}/verify-email?token=${token}`; - - const html = ` - - - - -
-

Verify your email

-

- Thanks for signing up to LibNovel. Click the button below to verify your email address. - The link expires in 24 hours. -

- - Verify email - -

- Or copy this link:
- ${link} -

-

- If you didn't create a LibNovel account, you can safely ignore this email. -

-
- -`; - - const text = `Verify your LibNovel email address\n\nClick this link to verify your account (expires in 24 hours):\n${link}\n\nIf you didn't sign up, ignore this email.`; - - try { - await sendSmtp({ to, subject: 'Verify your LibNovel email', html, text }); - log.info('email', 'verification email sent', { to }); - } catch (err) { - log.error('email', 'failed to send verification email', { to, err: String(err) }); - throw err; - } -} diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index e1db3d0..dd2078f 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -67,6 +67,8 @@ export interface User { email_verified?: boolean; verification_token?: string; verification_token_exp?: string; + oauth_provider?: string; + oauth_id?: string; } // ─── Auth token cache ───────────────────────────────────────────────────────── @@ -496,8 +498,75 @@ export async function getUserByEmail(email: string): Promise { return listOne('app_users', `email="${email.replace(/"/g, '\\"')}"`); } +/** + * Look up a user by OAuth provider + provider user ID. Returns null if not found. + */ +export async function getUserByOAuth(provider: string, oauthId: string): Promise { + return listOne( + 'app_users', + `oauth_provider="${provider.replace(/"/g, '\\"')}"&&oauth_id="${oauthId.replace(/"/g, '\\"')}"` + ); +} + +/** + * Create a new user via OAuth (no password). email_verified is true since the + * provider already verified it. Throws on DB errors. + */ +export async function createOAuthUser( + username: string, + email: string, + provider: string, + oauthId: string, + avatarUrl?: string, + role = 'user' +): Promise { + log.info('pocketbase', 'createOAuthUser', { username, email, provider }); + const res = await pbPost('/api/collections/app_users/records', { + username, + password_hash: '', + role, + email, + email_verified: true, + oauth_provider: provider, + oauth_id: oauthId, + avatar_url: avatarUrl ?? '', + created: new Date().toISOString() + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'createOAuthUser: PocketBase rejected record', { + username, + status: res.status, + body + }); + throw new Error(`Failed to create OAuth user: ${res.status} ${body}`); + } + return res.json() as Promise; +} + +/** + * Link an OAuth provider to an existing user account. + */ +export async function linkOAuthToUser( + userId: string, + provider: string, + oauthId: string +): Promise { + const res = await pbPatch(`/api/collections/app_users/records/${userId}`, { + oauth_provider: provider, + oauth_id: oauthId, + email_verified: true + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'linkOAuthToUser: PATCH failed', { userId, status: res.status, body }); + throw new Error(`Failed to link OAuth: ${res.status}`); + } +} + /** * Look up a user by verification token. Returns null if not found. + * @deprecated Email verification removed — kept only for migration safety. */ export async function getUserByVerificationToken(token: string): Promise { return listOne('app_users', `verification_token="${token.replace(/"/g, '\\"')}"`); @@ -608,7 +677,7 @@ export async function changePassword( /** * Verify username + password. Returns the user on success, null on failure. - * Throws with message 'Email not verified' if the account exists but hasn't been verified. + * Only used for legacy accounts that still have a password_hash. */ export async function loginUser(username: string, password: string): Promise { log.debug('pocketbase', 'loginUser: lookup', { username }); @@ -617,15 +686,15 @@ export async function loginUser(username: string, password: string): Promise { - if (!PUBLIC_ROUTES.has(url.pathname) && !locals.user) { + // Allow /auth/* (OAuth initiation + callbacks) without login + const isPublic = PUBLIC_ROUTES.has(url.pathname) || url.pathname.startsWith('/auth/'); + if (!isPublic && !locals.user) { redirect(302, `/login`); } diff --git a/ui/src/routes/api/auth/register/+server.ts b/ui/src/routes/api/auth/register/+server.ts index a55cf90..bc82832 100644 --- a/ui/src/routes/api/auth/register/+server.ts +++ b/ui/src/routes/api/auth/register/+server.ts @@ -1,66 +1,12 @@ -import { json, error } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { createUser } from '$lib/server/pocketbase'; -import { sendVerificationEmail } from '$lib/server/email'; -import { log } from '$lib/server/logger'; /** * POST /api/auth/register - * Body: { username: string, email: string, password: string } - * Returns: { pending_verification: true, email: string } * - * Account is created but NOT activated until the user clicks the verification - * link sent to their email. The iOS app should show a "check your inbox" screen. + * Username/password registration has been replaced by OAuth2 (Google & GitHub). + * This endpoint is no longer supported. */ -export const POST: RequestHandler = async ({ request }) => { - let body: { username?: string; email?: string; password?: string }; - try { - body = await request.json(); - } catch { - error(400, 'Invalid JSON body'); - } - - const username = (body.username ?? '').trim(); - const email = (body.email ?? '').trim().toLowerCase(); - const password = body.password ?? ''; - - if (!username || !email || !password) { - error(400, 'Username, email and password are required'); - } - if (username.length < 3 || username.length > 32) { - error(400, 'Username must be between 3 and 32 characters'); - } - if (!/^[a-zA-Z0-9_-]+$/.test(username)) { - error(400, 'Username may only contain letters, numbers, underscores and hyphens'); - } - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - error(400, 'Please enter a valid email address'); - } - if (password.length < 8) { - error(400, 'Password must be at least 8 characters'); - } - - let user; - try { - user = await createUser(username, password, email); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Registration failed.'; - if (msg.includes('Username already taken')) { - error(409, 'That username is already taken'); - } - if (msg.includes('Email already in use')) { - error(409, 'That email address is already registered'); - } - log.error('api/auth/register', 'unexpected error', { username, err: String(e) }); - error(500, 'An error occurred. Please try again.'); - } - - // Send verification email (non-fatal) - try { - await sendVerificationEmail(email, user.verification_token!); - } catch (e) { - log.error('api/auth/register', 'failed to send verification email', { username, email, err: String(e) }); - } - - return json({ pending_verification: true, email }); +export const POST: RequestHandler = async () => { + error(410, 'Username/password registration is no longer supported. Please sign in with Google or GitHub.'); }; diff --git a/ui/src/routes/auth/[provider]/+server.ts b/ui/src/routes/auth/[provider]/+server.ts new file mode 100644 index 0000000..34c9b00 --- /dev/null +++ b/ui/src/routes/auth/[provider]/+server.ts @@ -0,0 +1,79 @@ +/** + * GET /auth/[provider] + * + * Initiates the OAuth2 authorization code flow. + * Generates a random `state` param (stored in a short-lived cookie) to + * prevent CSRF, then redirects the browser to the provider's auth URL. + * + * Supported providers: google, github + */ + +import { redirect, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { randomBytes } from 'node:crypto'; + +const PROVIDERS = { + google: { + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: 'openid email profile' + }, + github: { + authUrl: 'https://github.com/login/oauth/authorize', + scopes: 'read:user user:email' + } +} as const; + +type Provider = keyof typeof PROVIDERS; + +function clientId(provider: Provider): string { + if (provider === 'google') return env.GOOGLE_CLIENT_ID ?? ''; + if (provider === 'github') return env.GITHUB_CLIENT_ID ?? ''; + return ''; +} + +function redirectUri(provider: Provider, origin: string): string { + return `${origin}/auth/${provider}/callback`; +} + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const provider = params.provider as Provider; + if (!(provider in PROVIDERS)) { + error(404, 'Unknown OAuth provider'); + } + + const id = clientId(provider); + if (!id) { + error(500, `OAuth provider "${provider}" is not configured`); + } + + // Generate state token — stored in a 10-minute cookie + const state = randomBytes(16).toString('hex'); + cookies.set(`oauth_state_${provider}`, state, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 60 * 10 // 10 minutes + }); + + // Where to send the user after successful auth (default: home) + const next = url.searchParams.get('next') ?? '/'; + cookies.set(`oauth_next_${provider}`, next, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 60 * 10 + }); + + const origin = url.origin; + const cfg = PROVIDERS[provider]; + const params2 = new URLSearchParams({ + client_id: id, + redirect_uri: redirectUri(provider, origin), + response_type: 'code', + scope: cfg.scopes, + state + }); + + redirect(302, `${cfg.authUrl}?${params2.toString()}`); +}; diff --git a/ui/src/routes/auth/[provider]/callback/+server.ts b/ui/src/routes/auth/[provider]/callback/+server.ts new file mode 100644 index 0000000..e96615c --- /dev/null +++ b/ui/src/routes/auth/[provider]/callback/+server.ts @@ -0,0 +1,246 @@ +/** + * GET /auth/[provider]/callback + * + * Handles the OAuth2 authorization code callback. + * + * Flow: + * 1. Validate state cookie (CSRF check). + * 2. Exchange code for access token with the provider. + * 3. Fetch the user's profile (email, name, avatar) from the provider. + * 4. Look up app_users by (oauth_provider, oauth_id). + * - If found: log in. + * - If not found but email matches an existing user: link the account. + * - If not found at all: auto-create a new account. + * 5. Set auth cookie, redirect to `next` (default: '/'). + */ + +import { redirect, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { randomBytes } from 'node:crypto'; +import { + getUserByOAuth, + getUserByEmail, + createOAuthUser, + linkOAuthToUser +} from '$lib/server/pocketbase'; +import { createAuthToken } from '../../../../hooks.server'; +import { createUserSession, mergeSessionProgress } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +type Provider = 'google' | 'github'; + +const AUTH_COOKIE = 'libnovel_auth'; +const ONE_YEAR = 60 * 60 * 24 * 365; + +// ─── Token exchange ─────────────────────────────────────────────────────────── + +interface TokenResponse { + access_token: string; + token_type: string; + error?: string; +} + +async function exchangeCode( + provider: Provider, + code: string, + redirectUri: string +): Promise { + const clientId = provider === 'google' ? env.GOOGLE_CLIENT_ID : env.GITHUB_CLIENT_ID; + const clientSecret = + provider === 'google' ? env.GOOGLE_CLIENT_SECRET : env.GITHUB_CLIENT_SECRET; + + const tokenUrl = + provider === 'google' + ? 'https://oauth2.googleapis.com/token' + : 'https://github.com/login/oauth/access_token'; + + const res = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + }, + body: new URLSearchParams({ + code, + client_id: clientId ?? '', + client_secret: clientSecret ?? '', + redirect_uri: redirectUri, + grant_type: 'authorization_code' + }).toString() + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('oauth', 'token exchange failed', { provider, status: res.status, body }); + throw new Error(`Token exchange failed: ${res.status}`); + } + + const data = (await res.json()) as TokenResponse; + if (data.error || !data.access_token) { + log.error('oauth', 'token response error', { provider, error: data.error }); + throw new Error(data.error ?? 'No access_token in response'); + } + return data.access_token; +} + +// ─── Profile fetching ───────────────────────────────────────────────────────── + +interface OAuthProfile { + id: string; // provider's user ID (as string) + email: string; + name: string; + avatarUrl?: string; +} + +async function fetchGoogleProfile(accessToken: string): Promise { + const res = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) throw new Error(`Google userinfo failed: ${res.status}`); + const d = await res.json(); + return { + id: String(d.id), + email: d.email ?? '', + name: d.name ?? d.email ?? '', + avatarUrl: d.picture + }; +} + +async function fetchGitHubProfile(accessToken: string): Promise { + const [userRes, emailRes] = await Promise.all([ + fetch('https://api.github.com/user', { + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' } + }), + fetch('https://api.github.com/user/emails', { + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' } + }) + ]); + + if (!userRes.ok) throw new Error(`GitHub user API failed: ${userRes.status}`); + const user = await userRes.json(); + + // Primary verified email — required for account linking + let email = user.email ?? ''; + if (emailRes.ok) { + const emails = (await emailRes.json()) as Array<{ + email: string; + primary: boolean; + verified: boolean; + }>; + const primary = emails.find((e) => e.primary && e.verified); + if (primary) email = primary.email; + } + + if (!email) throw new Error('GitHub account has no verified primary email'); + + return { + id: String(user.id), + email, + name: user.name ?? user.login ?? email, + avatarUrl: user.avatar_url + }; +} + +// ─── Username derivation ────────────────────────────────────────────────────── + +/** Derive a valid username from name/email. Sanitises to [a-zA-Z0-9_-], max 32 chars. */ +function deriveUsername(name: string, email: string): string { + // Prefer the part before @ in the email for predictability + const base = (email.split('@')[0] ?? name) + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 28); + // Append 4 random hex chars to avoid collisions without needing a DB round-trip + const suffix = randomBytes(2).toString('hex'); + return `${base || 'user'}_${suffix}`; +} + +// ─── Handler ────────────────────────────────────────────────────────────────── + +export const GET: RequestHandler = async ({ params, url, cookies, locals }) => { + const provider = params.provider as Provider; + if (provider !== 'google' && provider !== 'github') { + error(404, 'Unknown OAuth provider'); + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const storedState = cookies.get(`oauth_state_${provider}`); + const next = cookies.get(`oauth_next_${provider}`) ?? '/'; + + // Clear short-lived cookies + cookies.delete(`oauth_state_${provider}`, { path: '/' }); + cookies.delete(`oauth_next_${provider}`, { path: '/' }); + + if (!code || !state || state !== storedState) { + log.warn('oauth', 'state mismatch or missing code', { provider }); + redirect(302, '/login?error=oauth_state'); + } + + const redirectUri = `${url.origin}/auth/${provider}/callback`; + + let profile: OAuthProfile; + try { + const accessToken = await exchangeCode(provider, code, redirectUri); + profile = + provider === 'google' + ? await fetchGoogleProfile(accessToken) + : await fetchGitHubProfile(accessToken); + } catch (err) { + log.error('oauth', 'profile fetch failed', { provider, err: String(err) }); + redirect(302, '/login?error=oauth_failed'); + } + + if (!profile.email) { + log.warn('oauth', 'no email in profile', { provider, id: profile.id }); + redirect(302, '/login?error=oauth_no_email'); + } + + // ── Find or create user ──────────────────────────────────────────────────── + + let user = await getUserByOAuth(provider, profile.id); + + if (!user) { + // Try to link by email (user may have registered via the other provider) + const existing = await getUserByEmail(profile.email); + if (existing) { + // Link this provider to the existing account + await linkOAuthToUser(existing.id, provider, profile.id); + user = existing; + log.info('oauth', 'linked provider to existing account', { + provider, + userId: existing.id + }); + } else { + // Auto-create a new account + const username = deriveUsername(profile.name, profile.email); + user = await createOAuthUser(username, profile.email, provider, profile.id, profile.avatarUrl); + log.info('oauth', 'created new account via oauth', { provider, username }); + } + } + + // ── Merge anonymous session progress ─────────────────────────────────────── + mergeSessionProgress(locals.sessionId, user.id).catch((err) => + log.warn('oauth', 'mergeSessionProgress failed (non-fatal)', { err: String(err) }) + ); + + // ── Create session + auth cookie ────────────────────────────────────────── + const authSessionId = randomBytes(16).toString('hex'); + const userAgent = '' ; // not available in RequestHandler — omit + const ip = ''; + createUserSession(user.id, authSessionId, userAgent, ip).catch((err) => + log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) }) + ); + + const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); + cookies.set(AUTH_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + + redirect(302, next); +}; diff --git a/ui/src/routes/login/+page.server.ts b/ui/src/routes/login/+page.server.ts index 21e3827..4bb858f 100644 --- a/ui/src/routes/login/+page.server.ts +++ b/ui/src/routes/login/+page.server.ts @@ -1,140 +1,12 @@ -import { fail, redirect } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -import { loginUser, createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase'; -import { sendVerificationEmail } from '$lib/server/email'; -import { createAuthToken } from '../../hooks.server'; -import { log } from '$lib/server/logger'; -import { randomBytes } from 'node:crypto'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; -const AUTH_COOKIE = 'libnovel_auth'; -const ONE_YEAR = 60 * 60 * 24 * 365; - -export const load: PageServerLoad = async ({ locals }) => { +export const load: PageServerLoad = async ({ locals, url }) => { // Already logged in — send to home if (locals.user) { redirect(302, '/'); } - return {}; -}; - -export const actions: Actions = { - login: async ({ request, cookies, locals }) => { - const data = await request.formData(); - const username = (data.get('username') as string | null)?.trim() ?? ''; - const password = (data.get('password') as string | null) ?? ''; - - if (!username || !password) { - return fail(400, { action: 'login', error: 'Username and password are required.' }); - } - - let user; - try { - user = await loginUser(username, password); - } catch (err) { - const msg = err instanceof Error ? err.message : ''; - if (msg === 'Email not verified') { - return fail(403, { - action: 'login', - error: 'Please verify your email before signing in. Check your inbox for the verification link.' - }); - } - log.error('auth', 'login unexpected error', { username, err: String(err) }); - return fail(500, { action: 'login', error: 'An error occurred. Please try again.' }); - } - - if (!user) { - return fail(401, { action: 'login', error: 'Invalid username or password.' }); - } - - // Merge any anonymous session progress into the user's account so that - // chapters read before logging in are preserved and portable across devices. - mergeSessionProgress(locals.sessionId, user.id).catch((err) => - log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) }) - ); - - // Create a unique auth session ID for this login - const authSessionId = randomBytes(16).toString('hex'); - - // Record the session in PocketBase (best-effort, non-fatal) - const userAgent = request.headers.get('user-agent') ?? ''; - const ip = - request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? - request.headers.get('x-real-ip') ?? - ''; - createUserSession(user.id, authSessionId, userAgent, ip).catch((err) => - log.warn('auth', 'login: createUserSession failed (non-fatal)', { err: String(err) }) - ); - - const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); - cookies.set(AUTH_COOKIE, token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - maxAge: ONE_YEAR - }); - - redirect(302, '/'); - }, - - register: async ({ request }) => { - const data = await request.formData(); - const username = (data.get('username') as string | null)?.trim() ?? ''; - const email = (data.get('email') as string | null)?.trim().toLowerCase() ?? ''; - const password = (data.get('password') as string | null) ?? ''; - const confirm = (data.get('confirm') as string | null) ?? ''; - - if (!username || !email || !password) { - return fail(400, { action: 'register', error: 'All fields are required.' }); - } - if (username.length < 3 || username.length > 32) { - return fail(400, { - action: 'register', - error: 'Username must be between 3 and 32 characters.' - }); - } - if (!/^[a-zA-Z0-9_-]+$/.test(username)) { - return fail(400, { - action: 'register', - error: 'Username may only contain letters, numbers, underscores and hyphens.' - }); - } - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - return fail(400, { action: 'register', error: 'Please enter a valid email address.' }); - } - if (password.length < 8) { - return fail(400, { - action: 'register', - error: 'Password must be at least 8 characters.' - }); - } - if (password !== confirm) { - return fail(400, { action: 'register', error: 'Passwords do not match.' }); - } - - let user; - try { - user = await createUser(username, password, email); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : 'Registration failed.'; - if (msg.includes('Username already taken')) { - return fail(409, { action: 'register', error: 'That username is already taken.' }); - } - if (msg.includes('Email already in use')) { - return fail(409, { action: 'register', error: 'That email address is already registered.' }); - } - log.error('auth', 'register unexpected error', { username, err: String(err) }); - return fail(500, { action: 'register', error: 'An error occurred. Please try again.' }); - } - - // Send verification email (non-fatal — user can re-request later) - try { - await sendVerificationEmail(email, user.verification_token!); - } catch (err) { - log.error('auth', 'register: failed to send verification email', { username, email, err: String(err) }); - // Don't fail registration if email fails — user sees the pending screen - } - - // Return success state — do NOT log the user in yet - return { action: 'register', registered: true, email }; - } + // Surface provider error codes to the page (oauth_state, oauth_failed, etc.) + const error = url.searchParams.get('error') ?? undefined; + return { error }; }; diff --git a/ui/src/routes/login/+page.svelte b/ui/src/routes/login/+page.svelte index d797a1b..90863d5 100644 --- a/ui/src/routes/login/+page.svelte +++ b/ui/src/routes/login/+page.svelte @@ -1,12 +1,13 @@ @@ -16,155 +17,71 @@
- - {#if f?.registered} -
-
✉️
-

Check your inbox

-

- We sent a verification link to {f?.email}. - Click it to activate your account. -

-

- Didn't receive it? Check your spam folder, or - try again. -

-
- {:else} - -
- - -
+
+

Sign in to libnovel

+

Choose a provider to continue

+
- {#if form?.error && (form?.action === mode || !form?.action)} -
- {form.error} -
- {/if} - - {#if mode === 'login'} -
-
- - -
-
- - -
- -
- {:else} -
-
- - -

3–32 characters: letters, numbers, _ or -

-
-
- - -

Used to verify your account — not shown publicly

-
-
- - -

At least 8 characters

-
-
- - -
- -
- {/if} + {#if data.error && errorMessages[data.error]} +
+ {errorMessages[data.error]} +
{/if} + + + +

+ By signing in you agree to our terms of service. +

diff --git a/ui/src/routes/verify-email/+page.server.ts b/ui/src/routes/verify-email/+page.server.ts deleted file mode 100644 index 0038769..0000000 --- a/ui/src/routes/verify-email/+page.server.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { - getUserByVerificationToken, - verifyUserEmail, - createUserSession -} from '$lib/server/pocketbase'; -import { createAuthToken } from '../../hooks.server'; -import { log } from '$lib/server/logger'; -import { randomBytes } from 'node:crypto'; - -const AUTH_COOKIE = 'libnovel_auth'; -const ONE_YEAR = 60 * 60 * 24 * 365; - -export const load: PageServerLoad = async ({ url, cookies, request }) => { - const token = url.searchParams.get('token') ?? ''; - - if (!token) { - return { success: false, error: 'Missing verification token.' }; - } - - let user; - try { - user = await getUserByVerificationToken(token); - } catch (e) { - log.error('verify-email', 'lookup failed', { err: String(e) }); - return { success: false, error: 'An error occurred. Please try again.' }; - } - - if (!user) { - return { success: false, error: 'Invalid or expired verification link.' }; - } - - // Check expiry - if (user.verification_token_exp) { - const exp = new Date(user.verification_token_exp).getTime(); - if (Date.now() > exp) { - return { success: false, error: 'This verification link has expired. Please register again.' }; - } - } - - // Mark email as verified - try { - await verifyUserEmail(user.id); - } catch (e) { - log.error('verify-email', 'verifyUserEmail failed', { userId: user.id, err: String(e) }); - return { success: false, error: 'Failed to verify email. Please try again.' }; - } - - // Log the user in automatically - const authSessionId = randomBytes(16).toString('hex'); - const userAgent = request.headers.get('user-agent') ?? ''; - const ip = - request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? - request.headers.get('x-real-ip') ?? - ''; - - createUserSession(user.id, authSessionId, userAgent, ip).catch((e) => - log.warn('verify-email', 'createUserSession failed (non-fatal)', { err: String(e) }) - ); - - const authToken = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); - cookies.set(AUTH_COOKIE, authToken, { - path: '/', - httpOnly: true, - sameSite: 'lax', - maxAge: ONE_YEAR - }); - - log.info('verify-email', 'email verified, user logged in', { userId: user.id, username: user.username }); - redirect(302, '/'); -}; diff --git a/ui/src/routes/verify-email/+page.svelte b/ui/src/routes/verify-email/+page.svelte deleted file mode 100644 index fcbe853..0000000 --- a/ui/src/routes/verify-email/+page.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - - Verify email — libnovel - - -
-
- {#if data.error} -
- {data.error} -
- - Back to sign in - - {/if} -
-