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
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:
246
ui/src/routes/auth/[provider]/callback/+server.ts
Normal file
246
ui/src/routes/auth/[provider]/callback/+server.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user