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:
@@ -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;
|
||||||
Reference in New Issue
Block a user