Files
personal-support-ticket-system/backend/src/routes/auth.ts
2026-03-10 02:31:21 +09:00

112 lines
3.3 KiB
TypeScript

import type { FastifyPluginAsync } from "fastify";
import { OAuth2Client } from "google-auth-library";
import {
uniqueNamesGenerator,
adjectives,
animals,
} from "unique-names-generator";
import { eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
const client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI,
);
const isProd = process.env.NODE_ENV === "production";
export const authRouter: FastifyPluginAsync = async (fastify) => {
// GET /api/auth/google — redirect to Google consent screen
fastify.get("/google", async (_req, reply) => {
const url = client.generateAuthUrl({
access_type: "offline",
scope: ["openid", "profile"],
});
reply.redirect(url);
});
// GET /api/auth/callback — exchange code, upsert user, set session
fastify.get<{ Querystring: { code?: string } }>(
"/callback",
async (req, reply) => {
const { code } = req.query;
if (!code) return reply.status(400).send({ error: "Missing code" });
const { tokens } = await client.getToken(code);
client.setCredentials(tokens);
const ticket = await client.verifyIdToken({
idToken: tokens.id_token!,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload()!;
// Look up existing user by Google ID
let user = db
.select()
.from(users)
.where(eq(users.googleId, payload.sub))
.get();
if (!user) {
let username = uniqueNamesGenerator({
dictionaries: [adjectives, animals],
separator: " ",
length: 2,
});
// Collision retry — append a random suffix if username is taken
const existing = db
.select({ id: users.id })
.from(users)
.where(eq(users.username, username))
.get();
if (existing) username += `-${Math.floor(Math.random() * 1000)}`;
const newUser = {
id: crypto.randomUUID(),
googleId: payload.sub,
username,
avatarUrl: payload.picture ?? null,
createdAt: new Date().toISOString(),
};
db.insert(users).values(newUser).run();
user = newUser;
}
req.session.user = user;
const base = process.env.FRONTEND_APP_URL ?? "http://localhost:5173";
const baseUrl = base.endsWith("/") ? base : `${base}/`;
reply.redirect(`${baseUrl}?login=1`);
},
);
// GET /api/auth/me — return current session user
fastify.get("/me", async (req, reply) => {
if (!req.isAuthenticated)
return reply.status(401).send({ error: "Unauthenticated" });
reply.send(req.user);
});
// POST /api/auth/logout
fastify.post("/logout", async (req, reply) => {
await req.session.destroy();
reply.send({ ok: true });
});
// GET /api/auth/csrf-token — used by the frontend to get a CSRF token
// before making state-mutating requests in production.
// In dev this route still exists but returns a no-op token.
fastify.get("/csrf-token", async (req, reply) => {
if (isProd) {
const token = reply.generateCsrf();
return reply.send({ token });
}
return reply.send({ token: null });
});
};