feat(auth): replace email/password registration with OAuth2 (Google + GitHub)
Some checks failed
CI / Test backend (pull_request) Successful in 19s
Release / Test backend (push) Successful in 18s
CI / Check ui (pull_request) Successful in 41s
Release / Check ui (push) Successful in 21s
CI / Docker / backend (pull_request) Successful in 1m43s
CI / Docker / runner (pull_request) Successful in 1m28s
Release / Docker / backend (push) Successful in 1m40s
CI / Docker / caddy (pull_request) Successful in 6m45s
Release / Docker / runner (push) Successful in 1m48s
Release / Docker / caddy (push) Successful in 7m12s
CI / Docker / ui (pull_request) Successful in 1m20s
Release / Docker / ui (push) Successful in 1m19s
Release / Gitea Release (push) Failing after 2s

- 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)
This commit is contained in:
Admin
2026-03-24 22:01:51 +05:00
parent 920ac0d41b
commit 4a7009989c
12 changed files with 495 additions and 650 deletions

View File

@@ -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<string> {
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<OAuthProfile> {
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<OAuthProfile> {
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);
};