Import source: app/backend/src/routes/oauth

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:20:25 +03:30
parent db9114a1df
commit 682508a1b9
+257
View File
@@ -0,0 +1,257 @@
// OAuth — Google + GitHub sign-in (authorization-code grant).
//
// Flow:
// 1. GET /api/auth/oauth/:provider/start
// Issues a state token (stored in Redis 10min), redirects to provider's
// consent screen.
// 2. GET /api/auth/oauth/:provider/callback?code=…&state=…
// Validates state, exchanges code → access_token, fetches the user
// profile, upserts a User row (role: 'reader'), creates a session,
// redirects to "/".
//
// Env vars (see .env.prod.example):
// OAUTH_CALLBACK_BASE e.g. https://teknav.ir
// GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
// GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET
// If a provider's vars are missing, that provider's routes return 503.
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { randomBytes } from 'node:crypto';
import { prisma } from '../db.js';
import { redis } from '../redis.js';
import { config } from '../config.js';
import type { SessionData } from '../plugins/session.js';
const STATE_TTL_SECONDS = 10 * 60;
const USERNAME_RE = /^[a-z0-9_][a-z0-9_.]{1,28}[a-z0-9_]$/;
interface ProviderConfig {
authUrl: string;
tokenUrl: string;
userUrl: string;
scope: string;
clientId?: string;
clientSecret?: string;
parseProfile: (profile: any, accessToken: string) => Promise<{
sub: string;
email: string | null;
name: string;
avatarUrl: string | null;
}>;
}
function baseUsername(input: string) {
const base = input
.toLowerCase()
.replace(/^@/, '')
.replace(/@.*$/, '')
.replace(/[^a-z0-9_.]/g, '_')
.replace(/^[^a-z0-9_]+/, '')
.replace(/[^a-z0-9_]+$/, '')
.slice(0, 24);
return USERNAME_RE.test(base) ? base : `user_${randomBytes(3).toString('hex')}`;
}
async function uniqueUsername(seed: string) {
const base = baseUsername(seed);
for (let i = 0; i < 20; i += 1) {
const candidate = i === 0 ? base : `${base}_${i}`;
const taken = await prisma.user.findUnique({ where: { username: candidate } });
if (!taken) return candidate;
}
return `${base}_${randomBytes(3).toString('hex')}`.slice(0, 30);
}
const PROVIDERS: Record<string, ProviderConfig> = {
google: {
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
scope: 'openid email profile',
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
parseProfile: async (p) => ({
sub: String(p.sub),
email: p.email ?? null,
name: p.name || p.given_name || (p.email ? p.email.split('@')[0] : 'کاربر گوگل'),
avatarUrl: p.picture ?? null,
}),
},
github: {
authUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userUrl: 'https://api.github.com/user',
scope: 'read:user user:email',
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
parseProfile: async (p, accessToken) => {
let email: string | null = p.email ?? null;
// GitHub may not return a public email. Fetch the verified primary if not.
if (!email) {
try {
const r = await fetch('https://api.github.com/user/emails', {
headers: { Authorization: `Bearer ${accessToken}`, 'User-Agent': 'teknav-oauth' },
});
if (r.ok) {
const arr = await r.json() as Array<{ email: string; primary: boolean; verified: boolean }>;
const primary = arr.find((e) => e.primary && e.verified) || arr.find((e) => e.verified);
email = primary?.email ?? null;
}
} catch { /* swallow */ }
}
return {
sub: String(p.id),
email,
name: p.name || p.login || 'کاربر گیت‌هاب',
avatarUrl: p.avatar_url ?? null,
};
},
},
};
const callbackBase = (process.env.OAUTH_CALLBACK_BASE || '').replace(/\/$/, '');
const redirectUri = (provider: string) => `${callbackBase}/api/auth/oauth/${provider}/callback`;
const stateKey = (s: string) => `oauth:state:${s}`;
export default async function oauthRoutes(app: FastifyInstance) {
app.get<{ Params: { provider: string } }>(
'/api/v1/auth/oauth/:provider/start',
async (req: FastifyRequest<{ Params: { provider: string } }>, reply: FastifyReply) => {
const cfg = PROVIDERS[req.params.provider];
if (!cfg) return reply.code(404).send({ error: 'unknown_provider' });
if (!cfg.clientId || !cfg.clientSecret || !callbackBase) {
return reply.code(503).send({ error: 'oauth_not_configured', provider: req.params.provider });
}
const state = randomBytes(24).toString('base64url');
await redis.set(stateKey(state), '1', 'EX', STATE_TTL_SECONDS);
const params = new URLSearchParams({
client_id: cfg.clientId,
redirect_uri: redirectUri(req.params.provider),
response_type: 'code',
scope: cfg.scope,
state,
...(req.params.provider === 'google' ? { access_type: 'online', prompt: 'select_account' } : {}),
});
return reply.redirect(`${cfg.authUrl}?${params.toString()}`);
},
);
app.get<{
Params: { provider: string };
Querystring: { code?: string; state?: string; error?: string };
}>('/api/v1/auth/oauth/:provider/callback', async (req, reply) => {
const cfg = PROVIDERS[req.params.provider];
if (!cfg) return reply.code(404).send({ error: 'unknown_provider' });
if (!cfg.clientId || !cfg.clientSecret || !callbackBase) {
return reply.code(503).send({ error: 'oauth_not_configured' });
}
const { code, state, error } = req.query;
if (error) return reply.redirect(`/login?error=${encodeURIComponent(error)}`);
if (!code || !state) return reply.redirect('/login?error=missing_code');
const stateOk = await redis.get(stateKey(state));
if (!stateOk) return reply.redirect('/login?error=bad_state');
await redis.del(stateKey(state));
// 1. Exchange code → access_token.
const tokenRes = await fetch(cfg.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
code,
redirect_uri: redirectUri(req.params.provider),
grant_type: 'authorization_code',
}).toString(),
});
if (!tokenRes.ok) return reply.redirect('/login?error=token_exchange_failed');
const tokenJson = await tokenRes.json() as { access_token?: string };
const accessToken = tokenJson.access_token;
if (!accessToken) return reply.redirect('/login?error=no_access_token');
// 2. Fetch user profile.
const profileRes = await fetch(cfg.userUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'User-Agent': 'teknav-oauth',
},
});
if (!profileRes.ok) return reply.redirect('/login?error=profile_fetch_failed');
const profile = await cfg.parseProfile(await profileRes.json(), accessToken);
// 3. Upsert user. Match first by oauthProvider+oauthSubject, then by email.
let user = await prisma.user.findUnique({
where: { oauthProvider_oauthSubject: { oauthProvider: req.params.provider, oauthSubject: profile.sub } },
});
if (!user && profile.email) {
user = await prisma.user.findUnique({ where: { email: profile.email } });
if (user) {
// Existing email account — link the OAuth identity to it.
user = await prisma.user.update({
where: { id: user.id },
data: {
oauthProvider: req.params.provider,
oauthSubject: profile.sub,
...(user.username ? {} : { username: await uniqueUsername(profile.email || profile.name) }),
avatarUrl: user.avatarUrl ?? profile.avatarUrl,
lastLoginAt: new Date(),
},
});
}
}
if (!user) {
// New account — role = reader, no password (OAuth-only).
user = await prisma.user.create({
data: {
email: profile.email ?? `${req.params.provider}+${profile.sub}@oauth.teknav.local`,
username: await uniqueUsername(profile.email || profile.name || profile.sub),
name: profile.name,
avatarUrl: profile.avatarUrl,
role: 'reader',
status: 'active',
oauthProvider: req.params.provider,
oauthSubject: profile.sub,
lastLoginAt: new Date(),
},
});
} else {
user = await prisma.user.update({
where: { id: user.id },
data: {
lastLoginAt: new Date(),
...(user.username ? {} : { username: await uniqueUsername(profile.email || profile.name || user.id) }),
},
});
}
if (user.status !== 'active') {
return reply.redirect(`/login?error=account_${user.status}`);
}
await reply.createSession({
userId: user.id,
role: user.role as SessionData['role'],
});
return reply.redirect('/');
});
// Helper for the frontend — which providers are configured?
app.get('/api/v1/auth/oauth/providers', async () => ({
providers: Object.entries(PROVIDERS)
.filter(([, p]) => p.clientId && p.clientSecret && callbackBase)
.map(([k]) => k),
}));
}
// Suppress "config used" lint when config isn't referenced directly.
void config;