Auth Session & OAuth2 Scaffolder
1. System Architecture & Prerequisites
- Node.js >= 18 LTS (runtime), npm >= 9, TypeScript >= 5.5,
ts-node-dev@^2.0.0 for watch mode.
- Core deps:
express@^4.19.2, argon2@^0.41.1 (Argon2id), jsonwebtoken@^9.0.2, cookie-parser@^1.4.6, redis@^4.7.0, pg@^8.12.0, nodemailer@^6.9.14, passport@^0.7.0, passport-google-oauth20@^2.0.0, passport-github2@^0.1.12, zod@^3.23.8, helmet@^7.1.0, cors@^2.8.5, dotenv@^16.4.5.
- Infrastructure: PostgreSQL >= 14 (schema auto-created by
initDb()), Redis >= 6 (refresh-token blacklist + optional revocation state).
argon2 ships prebuilt binaries via node-pre-gyp; if a native build is forced, a C++ toolchain (Visual Studio Build Tools) is required on Windows or build-essential on Linux.
- The JWT access secret and refresh secret are hard-required (min 32 chars) by the config loader; the server refuses to boot without them.
2. Input/Output Data Contracts
Request payloads (JSON)
POST /register → {"email": string(email), "password": string(8..72), "displayName": string(1..64)} → 201 {success, data:{user, requiresEmailVerification}}.
POST /login → {"email": string(email), "password": string} → 200 {success, data:{user}} + sets access_token/refresh_token cookies.
POST /refresh (no body) → reads refresh_token cookie → rotates token, sets new cookies → 200.
POST /logout (no body) → revokes refresh token + clears cookies → 200.
POST /forgot-password → {"email"} → always 200 (no user enumeration).
POST /reset-password → {"token": string, "password": string(8..72)} → 200, signs the user in.
POST /verify-email → {"token"} → 200.
GET /me (Bearer or cookie) → {success, data:{userId, email, displayName, emailVerified}}.
Cookie contract
access_token: HTTP-only, SameSite=Strict, Secure in prod, path /, TTL 15m.
refresh_token: HTTP-only, SameSite=Strict, Secure in prod, path /api/auth (scoped so it is only sent to auth endpoints), TTL 7d.
- OAuth2:
GET /api/auth/google, GET /api/auth/github initiate flows; /api/auth/google/callback, /api/auth/github/callback receive redirects, create/link accounts, and issue the same cookie pair.
Output artifact paths
skills/auth-session-oauth2-scaffolder/package.json, tsconfig.json, .env.example
src/config.ts, src/db.ts, src/redis.ts, src/mailer.ts, src/errors.ts, src/types.ts
src/auth.service.ts, src/auth.middleware.ts, src/auth.routes.ts, src/oauth.routes.ts, src/server.ts
3. Production Reference Implementation
# .env.example
NODE_ENV=development
PORT=4000
DATABASE_URL=postgres://postgres:postgres@localhost:5432/authdb
REDIS_URL=redis://127.0.0.1:6379
CORS_ORIGIN=http://localhost:3000
JWT_ACCESS_SECRET=change-me-at-least-32-chars-long-access
JWT_REFRESH_SECRET=change-me-at-least-32-chars-long-refresh
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL_SECONDS=604800
COOKIE_DOMAIN=
EMAIL_FROM=no-reply@example.com
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
OAUTH_REDIRECT_BASE=http://localhost:4000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
REQUIRE_EMAIL_VERIFICATION=false
// package.json
{
"name": "auth-session-oauth2",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "ts-node-dev --respawn src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js"
},
"dependencies": {
"argon2": "^0.41.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.14",
"passport": "^0.7.0",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"pg": "^8.12.0",
"redis": "^4.7.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.9",
"@types/nodemailer": "^6.4.15",
"@types/passport": "^1.0.16",
"@types/passport-github2": "^1.2.8",
"@types/passport-google-oauth20": "^2.0.16",
"@types/pg": "^8.11.6",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.2"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
// src/types.ts
export interface User {
id: string;
email: string;
password_hash: string | null;
display_name: string;
email_verified_at: string | null;
created_at: string;
}
export interface RefreshTokenRow {
jti: string;
user_id: string;
rotation_count: number;
expires_at: string;
created_at: string;
revoked_at: string | null;
}
export interface AuthenticatedUser {
userId: string;
email: string;
displayName: string;
}
// src/config.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().default(4000),
DATABASE_URL: z.string().default('postgres://postgres:postgres@localhost:5432/authdb'),
REDIS_URL: z.string().default('redis://127.0.0.1:6379'),
CORS_ORIGIN: z.string().optional(),
JWT_ACCESS_SECRET: z.string().min(32),
JWT_REFRESH_SECRET: z.string().min(32),
ACCESS_TOKEN_TTL: z.string().default('15m'),
REFRESH_TOKEN_TTL_SECONDS: z.coerce.number().int().default(7 * 24 * 60 * 60),
COOKIE_DOMAIN: z.string().optional(),
EMAIL_FROM: z.string().default('no-reply@example.com'),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().default(587),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
OAUTH_REDIRECT_BASE: z.string().default('http://localhost:4000'),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
REQUIRE_EMAIL_VERIFICATION: z.coerce.boolean().default(false)
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('[config] invalid environment', JSON.stringify(parsed.error.flatten(), null, 2));
process.exit(1);
}
export interface Config extends z.infer<typeof envSchema> {
isProd: boolean;
}
export const config: Config = { ...parsed.data, isProd: parsed.data.NODE_ENV === 'production' };
// src/db.ts
import { Pool } from 'pg';
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000
});
pool.on('error', (err) => {
console.error('[pg] idle client error', err);
});
export async function query<T extends Record<string, unknown> = Record<string, unknown>>(
text: string,
params: unknown[] = []
): Promise<T[]> {
const result = await pool.query<T>(text, params);
return result.rows;
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT,
display_name TEXT NOT NULL,
email_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS oauth_accounts (
provider TEXT NOT NULL,
provider_account_id TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
access_token TEXT,
refresh_token TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (provider, provider_account_id),
UNIQUE (user_id, provider)
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
jti UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rotation_count INT NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active
ON refresh_tokens (user_id) WHERE revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS email_verification_tokens (
token_hash TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
token_hash TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
`;
export async function initDb(): Promise<void> {
await pool.query(SCHEMA);
}
// src/redis.ts
import { createClient } from 'redis';
export const redis = createClient({
url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'
});
redis.on('error', (err) => {
console.error('[redis] client error', err);
});
export async function connectRedis(): Promise<void> {
if (!redis.isOpen) {
await redis.connect();
}
}
// src/mailer.ts
import nodemailer from 'nodemailer';
import { config } from './config';
const transporter = nodemailer.createTransport({
host: config.SMTP_HOST ?? 'localhost',
port: config.SMTP_PORT,
secure: config.isProd && config.SMTP_PORT === 465,
auth: config.SMTP_USER && config.SMTP_PASS ? { user: config.SMTP_USER, pass: config.SMTP_PASS } : undefined
});
export interface MailPayload {
to: string;
subject: string;
text: string;
html?: string;
}
export async function sendMail(payload: MailPayload): Promise<void> {
if (config.NODE_ENV === 'development' || config.NODE_ENV === 'test') {
console.log('[mailer][stub]', JSON.stringify({ from: config.EMAIL_FROM, ...payload }));
return;
}
await transporter.sendMail({ from: config.EMAIL_FROM, ...payload });
}
// src/errors.ts
import type { NextFunction, Request, Response } from 'express';
export class AppError extends Error {
public readonly statusCode: number;
public readonly code: string;
public readonly details?: unknown;
constructor(statusCode: number, code: string, message: string, details?: unknown) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
this.statusCode = statusCode;
this.code = code;
this.details = details;
Error.captureStackTrace(this, this.constructor);
}
public static badRequest(message: string, details?: unknown): AppError {
return new AppError(400, 'BAD_REQUEST', message, details);
}
public static unauthorized(message = 'Unauthorized'): AppError {
return new AppError(401, 'UNAUTHORIZED', message);
}
public static conflict(message: string): AppError {
return new AppError(409, 'CONFLICT', message);
}
public static internal(message = 'Internal server error'): AppError {
return new AppError(500, 'INTERNAL_ERROR', message);
}
}
export function notFoundHandler(req: Request, _res: Response, next: NextFunction): void {
next(new AppError(404, 'NOT_FOUND', `Route ${req.method} ${req.originalUrl} not found`));
}
export function errorHandler(err: unknown, _req: Request, res: Response, next: NextFunction): void {
if (res.headersSent) {
next(err);
return;
}
if (err instanceof AppError) {
res.status(err.statusCode).json({
success: false,
error: {
code: err.code,
message: err.message,
...(err.details === undefined ? {} : { details: err.details })
}
});
return;
}
const normalized = err instanceof SyntaxError ? AppError.badRequest('Malformed JSON body') : AppError.internal();
console.error('[error]', err);
res.status(normalized.statusCode).json({
success: false,
error: { code: normalized.code, message: normalized.message }
});
}
// src/auth.middleware.ts
import type { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { config } from './config';
import { AppError } from './errors';
import type { AuthenticatedUser } from './types';
export interface AuthenticatedRequest extends Request {
user: AuthenticatedUser;
}
export function extractAccessToken(req: Request): string | null {
const header = req.headers.authorization;
if (header && header.startsWith('Bearer ')) return header.slice(7);
const cookie = req.cookies?.['access_token'];
return typeof cookie === 'string' ? cookie : null;
}
export function requireAuth(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
const token = extractAccessToken(req);
if (!token) {
return next(AppError.unauthorized('Missing access token'));
}
let payload: { sub?: string; email?: string; displayName?: string; type?: string };
try {
payload = jwt.verify(token, config.JWT_ACCESS_SECRET) as typeof payload;
} catch {
return next(AppError.unauthorized('Invalid or expired access token'));
}
if (payload.type !== 'access' || typeof payload.sub !== 'string') {
return next(AppError.unauthorized('Invalid token type'));
}
req.user = {
userId: payload.sub,
email: payload.email ?? '',
displayName: payload.displayName ?? ''
};
next();
}
// src/auth.service.ts
import argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type { CookieOptions, Response } from 'express';
import { config } from './config';
import { pool, query } from './db';
import { sendMail } from './mailer';
import { redis } from './redis';
import { AppError } from './errors';
import type { AuthenticatedUser, RefreshTokenRow, User } from './types';
export const ACCESS_COOKIE = 'access_token';
export const REFRESH_COOKIE = 'refresh_token';
interface AccessPayload {
sub: string;
email: string;
displayName: string;
type: 'access';
}
interface RefreshPayload {
sub: string;
jti: string;
type: 'refresh';
}
export interface Session {
accessToken: string;
refreshToken: string;
user: AuthenticatedUser;
}
function toAuthenticatedUser(user: User): AuthenticatedUser {
return { userId: user.id, email: user.email, displayName: user.display_name };
}
function signAccessToken(user: AuthenticatedUser): string {
return jwt.sign(
{ sub: user.userId, email: user.email, displayName: user.displayName, type: 'access' },
config.JWT_ACCESS_SECRET,
{ expiresIn: config.ACCESS_TOKEN_TTL }
);
}
function signRefreshToken(userId: string, jti: string): string {
return jwt.sign(
{ sub: userId, jti, type: 'refresh' },
config.JWT_REFRESH_SECRET,
{ expiresIn: config.REFRESH_TOKEN_TTL_SECONDS }
);
}
function accessCookieOptions(): CookieOptions {
return {
httpOnly: true,
sameSite: 'strict',
secure: config.isProd,
domain: config.COOKIE_DOMAIN,
path: '/',
maxAge: 15 * 60 * 1000
};
}
function refreshCookieOptions(): CookieOptions {
return {
httpOnly: true,
sameSite: 'strict',
secure: config.isProd,
domain: config.COOKIE_DOMAIN,
path: '/api/auth',
maxAge: config.REFRESH_TOKEN_TTL_SECONDS * 1000
};
}
export function setSessionCookies(res: Response, session: Session): void {
res.cookie(ACCESS_COOKIE, session.accessToken, accessCookieOptions());
res.cookie(REFRESH_COOKIE, session.refreshToken, refreshCookieOptions());
}
export function clearSessionCookies(res: Response): void {
res.clearCookie(ACCESS_COOKIE, accessCookieOptions());
res.clearCookie(REFRESH_COOKIE, refreshCookieOptions());
}
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19 * 1024,
timeCost: 2,
parallelism: 1
});
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
export async function issueSession(user: User): Promise<Session> {
const jti = randomUUID();
const refreshToken = signRefreshToken(user.id, jti);
await query(
`INSERT INTO refresh_tokens (jti, user_id, rotation_count, expires_at)
VALUES ($1, $2, 0, now() + make_interval(secs => $3))`,
[jti, user.id, config.REFRESH_TOKEN_TTL_SECONDS]
);
return {
accessToken: signAccessToken(toAuthenticatedUser(user)),
refreshToken,
user: toAuthenticatedUser(user)
};
}
export async function register(input: {
email: string;
password: string;
displayName: string;
}): Promise<AuthenticatedUser> {
const email = input.email.trim().toLowerCase();
const existing = await query<User>('SELECT * FROM users WHERE email = $1', [email]);
if (existing.length > 0) throw AppError.conflict(`An account for ${email} already exists`);
if (input.password.length < 8) throw AppError.badRequest('Password must be at least 8 characters');
const passwordHash = await hashPassword(input.password);
const rows = await query<User>(
`INSERT INTO users (email, password_hash, display_name, email_verified_at)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[email, passwordHash, input.displayName, config.REQUIRE_EMAIL_VERIFICATION ? null : new Date().toISOString()]
);
const user = rows[0];
if (config.REQUIRE_EMAIL_VERIFICATION && user.email_verified_at === null) {
await sendVerificationEmail(user.id, user.email);
}
return toAuthenticatedUser(user);
}
export async function login(
input: { email: string; password: string },
res: Response
): Promise<AuthenticatedUser> {
const email = input.email.trim().toLowerCase();
const rows = await query<User>('SELECT * FROM users WHERE email = $1', [email]);
const user = rows[0];
if (!user || user.password_hash === null) throw AppError.unauthorized('Invalid email or password');
const valid = await verifyPassword(user.password_hash, input.password);
if (!valid) throw AppError.unauthorized('Invalid email or password');
if (config.REQUIRE_EMAIL_VERIFICATION && user.email_verified_at === null) {
throw AppError.unauthorized('Email not verified. Check your inbox.');
}
setSessionCookies(res, await issueSession(user));
return toAuthenticatedUser(user);
}
export async function logout(refreshToken: string | undefined, res: Response): Promise<void> {
if (refreshToken) {
try {
const payload = jwt.verify(refreshToken, config.JWT_REFRESH_SECRET) as RefreshPayload;
await query('UPDATE refresh_tokens SET revoked_at = now() WHERE jti = $1', [payload.jti]);
await redis.set(`refresh:denied:${payload.jti}`, '1', { EX: config.REFRESH_TOKEN_TTL_SECONDS });
} catch {
// Unknown or expired token: nothing to revoke.
}
}
clearSessionCookies(res);
}
export async function rotateRefreshToken(refreshToken: string, res: Response): Promise<AuthenticatedUser> {
let payload: RefreshPayload;
try {
payload = jwt.verify(refreshToken, config.JWT_REFRESH_SECRET) as RefreshPayload;
} catch {
throw AppError.unauthorized('Invalid or expired refresh token');
}
if (payload.type !== 'refresh') throw AppError.unauthorized('Invalid token type');
const blacklisted = await redis.get(`refresh:denied:${payload.jti}`);
if (blacklisted === '1') throw AppError.unauthorized('Refresh token revoked');
const rows = await query<RefreshTokenRow>('SELECT * FROM refresh_tokens WHERE jti = $1', [payload.jti]);
const row = rows[0];
if (!row) throw AppError.unauthorized('Unknown refresh token');
if (row.revoked_at !== null) {
await query('UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL', [row.user_id]);
await redis.set(`refresh:denied:${payload.jti}`, '1', { EX: config.REFRESH_TOKEN_TTL_SECONDS });
throw new AppError(401, 'TOKEN_REUSE_DETECTED', 'Refresh token reuse detected; session revoked');
}
if (new Date(row.expires_at).getTime() < Date.now()) {
throw AppError.unauthorized('Refresh token expired');
}
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE refresh_tokens SET revoked_at = now() WHERE jti = $1', [row.jti]);
const jti = randomUUID();
await client.query(
`INSERT INTO refresh_tokens (jti, user_id, rotation_count, expires_at)
VALUES ($1, $2, $3, now() + make_interval(secs => $4))`,
[jti, row.user_id, row.rotation_count + 1, config.REFRESH_TOKEN_TTL_SECONDS]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
const userRows = await query<User>('SELECT * FROM users WHERE id = $1', [row.user_id]);
const user = userRows[0];
if (!user) throw AppError.unauthorized('Account no longer exists');
setSessionCookies(res, await issueSession(user));
return toAuthenticatedUser(user);
}
async function sendVerificationEmail(userId: string, email: string): Promise<void> {
const token = randomBytes(24).toString('base64url');
const tokenHash = createHash('sha256').update(token).digest('hex');
await query(
`INSERT INTO email_verification_tokens (token_hash, user_id, expires_at)
VALUES ($1, $2, now() + interval '24 hours')`,
[tokenHash, userId]
);
await sendMail({
to: email,
subject: 'Verify your email',
text: `Verify your email: ${config.OAUTH_REDIRECT_BASE}/verify-email?token=${token}`
});
}
export async function verifyEmail(token: string): Promise<void> {
const tokenHash = createHash('sha256').update(token).digest('hex');
const rows = await query<{ token_hash: string; user_id: string; expires_at: string; used_at: string | null }>(
'SELECT * FROM email_verification_tokens WHERE token_hash = $1',
[tokenHash]
);
const row = rows[0];
if (!row) throw AppError.badRequest('Invalid verification token');
if (row.used_at !== null) throw AppError.badRequest('Verification token already used');
if (new Date(row.expires_at).getTime() < Date.now()) throw AppError.badRequest('Verification token expired');
await query('UPDATE users SET email_verified_at = now() WHERE id = $1', [row.user_id]);
await query('UPDATE email_verification_tokens SET used_at = now() WHERE token_hash = $1', [tokenHash]);
}
export async function forgotPassword(email: string): Promise<void> {
const rows = await query<User>('SELECT * FROM users WHERE email = $1', [email.trim().toLowerCase()]);
const user = rows[0];
if (!user || user.password_hash === null) return;
const token = randomBytes(32).toString('base64url');
const tokenHash = createHash('sha256').update(token).digest('hex');
await query(
`INSERT INTO password_reset_tokens (token_hash, user_id, expires_at)
VALUES ($1, $2, now() + interval '30 minutes')`,
[tokenHash, user.id]
);
await sendMail({
to: user.email,
subject: 'Password reset',
text: `Reset your password: ${config.OAUTH_REDIRECT_BASE}/reset-password?token=${token}`
});
}
export async function resetPassword(
token: string,
newPassword: string,
res: Response
): Promise<AuthenticatedUser> {
const tokenHash = createHash('sha256').update(token).digest('hex');
const rows = await query<{ token_hash: string; user_id: string; expires_at: string; used_at: string | null }>(
'SELECT * FROM password_reset_tokens WHERE token_hash = $1',
[tokenHash]
);
const row = rows[0];
if (!row) throw AppError.badRequest('Invalid reset token');
if (row.used_at !== null) throw AppError.badRequest('Reset token already used');
if (new Date(row.expires_at).getTime() < Date.now()) throw AppError.badRequest('Reset token expired');
if (newPassword.length < 8) throw AppError.badRequest('Password must be at least 8 characters');
const passwordHash = await hashPassword(newPassword);
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, row.user_id]);
await client.query('UPDATE password_reset_tokens SET used_at = now() WHERE token_hash = $1', [tokenHash]);
await client.query(
'UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
[row.user_id]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
const userRows = await query<User>('SELECT * FROM users WHERE id = $1', [row.user_id]);
const user = userRows[0];
setSessionCookies(res, await issueSession(user));
return toAuthenticatedUser(user);
}
export async function getProfile(
userId: string
): Promise<{ userId: string; email: string; displayName: string; emailVerified: boolean }> {
const rows = await query<User>('SELECT * FROM users WHERE id = $1', [userId]);
const user = rows[0];
if (!user) throw AppError.unauthorized('Account no longer exists');
return {
userId: user.id,
email: user.email,
displayName: user.display_name,
emailVerified: user.email_verified_at !== null
};
}
// src/auth.routes.ts
import { Router } from 'express';
import type { NextFunction, Request, Response } from 'express';
import { z } from 'zod';
import { config } from './config';
import { AppError } from './errors';
import { requireAuth, type AuthenticatedRequest } from './auth.middleware';
import * as authService from './auth.service';
const router = Router();
function validate<T>(schema: z.ZodSchema<T>) {
return (req: Request, _res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body ?? {});
if (!result.success) {
return next(
AppError.badRequest(
'Validation failed',
result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message }))
)
);
}
req.body = result.data;
next();
};
}
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(72),
displayName: z.string().min(1).max(64)
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
});
const forgotSchema = z.object({ email: z.string().email() });
const resetSchema = z.object({
token: z.string().min(1),
password: z.string().min(8).max(72)
});
const tokenSchema = z.object({ token: z.string().min(1) });
router.post('/register', validate(registerSchema), (req, res, next) => {
authService
.register(req.body as { email: string; password: string; displayName: string })
.then((user) => {
res.status(201).json({
success: true,
data: { user, requiresEmailVerification: config.REQUIRE_EMAIL_VERIFICATION }
});
})
.catch(next);
});
router.post('/login', validate(loginSchema), (req, res, next) => {
authService
.login(req.body as { email: string; password: string }, res)
.then((user) => res.status(200).json({ success: true, data: { user } }))
.catch(next);
});
router.post('/refresh', (req, res, next) => {
const token = req.cookies?.[authService.REFRESH_COOKIE];
if (typeof token !== 'string') return next(AppError.unauthorized('Missing refresh token'));
authService
.rotateRefreshToken(token, res)
.then((user) => res.status(200).json({ success: true, data: { user } }))
.catch(next);
});
router.post('/logout', (req, res, next) => {
const token = req.cookies?.[authService.REFRESH_COOKIE];
authService
.logout(typeof token === 'string' ? token : undefined, res)
.then(() => res.status(200).json({ success: true, data: null }))
.catch(next);
});
router.post('/forgot-password', validate(forgotSchema), (req, res, next) => {
authService
.forgotPassword((req.body as { email: string }).email)
.then(() => res.status(200).json({ success: true, data: null }))
.catch(next);
});
router.post('/reset-password', validate(resetSchema), (req, res, next) => {
const { token, password } = req.body as { token: string; password: string };
authService
.resetPassword(token, password, res)
.then((user) => res.status(200).json({ success: true, data: { user } }))
.catch(next);
});
router.post('/verify-email', validate(tokenSchema), (req, res, next) => {
authService
.verifyEmail((req.body as { token: string }).token)
.then(() => res.status(200).json({ success: true, data: null }))
.catch(next);
});
router.get('/me', requireAuth, (req: AuthenticatedRequest, res, next) => {
authService
.getProfile(req.user.userId)
.then((profile) => res.status(200).json({ success: true, data: profile }))
.catch(next);
});
export default router;
// src/oauth.routes.ts
import { Router } from 'express';
import type { NextFunction, Request, Response } from 'express';
import passport from 'passport';
import { Strategy as GoogleStrategy, type Profile as GoogleProfile } from 'passport-google-oauth20';
import { Strategy as GitHubStrategy, type Profile as GitHubProfile } from 'passport-github2';
import { config } from './config';
import { query } from './db';
import { AppError } from './errors';
import { issueSession, setSessionCookies } from './auth.service';
import type { User } from './types';
const router = Router();
passport.use(
new GoogleStrategy(
{
clientID: config.GOOGLE_CLIENT_ID ?? 'unset',
clientSecret: config.GOOGLE_CLIENT_SECRET ?? 'unset',
callbackURL: `${config.OAUTH_REDIRECT_BASE}/api/auth/google/callback`
},
(_accessToken, _refreshToken, profile, done) => {
done(null, profile);
}
)
);
passport.use(
new GitHubStrategy(
{
clientID: config.GITHUB_CLIENT_ID ?? 'unset',
clientSecret: config.GITHUB_CLIENT_SECRET ?? 'unset',
callbackURL: `${config.OAUTH_REDIRECT_BASE}/api/auth/github/callback`
},
(_accessToken, _refreshToken, profile, done) => {
done(null, profile);
}
)
);
async function findOrCreateOAuthUser(
provider: 'google' | 'github',
providerAccountId: string,
email: string | null,
displayName: string
): Promise<User> {
const links = await query<{ user_id: string }>(
'SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_account_id = $2',
[provider, providerAccountId]
);
if (links.length > 0) {
const users = await query<User>('SELECT * FROM users WHERE id = $1', [links[0].user_id]);
if (users.length > 0) return users[0];
}
let user: User | undefined;
if (email) {
const byEmail = await query<User>('SELECT * FROM users WHERE email = $1', [email.toLowerCase()]);
user = byEmail[0];
}
if (!user) {
const created = await query<User>(
`INSERT INTO users (email, password_hash, display_name, email_verified_at)
VALUES ($1, NULL, $2, $3)
RETURNING *`,
[email ?? `${providerAccountId}@${provider}.local`, displayName, email ? new Date().toISOString() : null]
);
user = created[0];
}
await query(
`INSERT INTO oauth_accounts (provider, provider_account_id, user_id, access_token, refresh_token)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (provider, provider_account_id) DO NOTHING`,
[provider, providerAccountId, user.id, null, null]
);
return user;
}
function oauthCallback(provider: 'google' | 'github') {
return (req: Request, res: Response, next: NextFunction): void => {
const profile = req.user as GoogleProfile | GitHubProfile;
if (!profile) {
return next(AppError.unauthorized('OAuth callback without profile'));
}
const email = profile.emails?.[0]?.value ?? null;
const username = 'username' in profile ? profile.username ?? undefined : undefined;
const displayName = profile.displayName ?? username ?? 'OAuth User';
findOrCreateOAuthUser(provider, profile.id, email, displayName)
.then(async (user) => {
setSessionCookies(res, await issueSession(user));
res.redirect(config.OAUTH_REDIRECT_BASE);
})
.catch(next);
};
}
router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'], session: false }));
router.get(
'/google/callback',
passport.authenticate('google', { session: false, failureRedirect: `${config.OAUTH_REDIRECT_BASE}/login` }),
oauthCallback('google')
);
router.get('/github', passport.authenticate('github', { session: false }));
router.get(
'/github/callback',
passport.authenticate('github', { session: false, failureRedirect: `${config.OAUTH_REDIRECT_BASE}/login` }),
oauthCallback('github')
);
export default router;
// src/server.ts
import 'dotenv/config';
import type { Server } from 'node:http';
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import helmet from 'helmet';
import { config } from './config';
import { initDb } from './db';
import { connectRedis } from './redis';
import { errorHandler, notFoundHandler } from './errors';
import authRouter from './auth.routes';
import oauthRouter from './oauth.routes';
export async function startServer(): Promise<Server> {
await Promise.all([initDb(), connectRedis()]);
const app = express();
app.set('trust proxy', config.isProd ? 1 : 0);
app.use(helmet());
app.use(cors({ origin: config.CORS_ORIGIN ?? '*', credentials: true }));
app.use(express.json({ limit: '256kb' }));
app.use(cookieParser());
app.get('/health', (_req, res) => {
res.status(200).json({ success: true, data: { status: 'ok' } });
});
app.use('/api/auth', authRouter);
app.use('/api/auth', oauthRouter);
app.use(notFoundHandler);
app.use(errorHandler);
return new Promise((resolve) => {
const server = app.listen(config.PORT, () => {
console.log(`[auth] api listening on http://localhost:${config.PORT}`);
resolve(server);
});
});
}
if (require.main === module) {
startServer().catch((err) => {
console.error('[auth] startup failed', err);
process.exit(1);
});
}
4. Execution Protocol & Step-by-Step Workflow
- Scaffold: create the project directory and copy all files from section 3 (package.json, tsconfig.json,
.env.example, src/*).
- Provision infra:
docker run -d --name authdb -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=authdb postgres:16 and docker run -d --name authredis -p 6379:6379 redis:7.
- Configure: copy
.env.example to .env and replace both JWT_*_SECRET values with openssl rand -hex 32 output; fill OAuth client ids/secrets if Google/GitHub flows will be used.
- Install: run
npm install.
- Migrate: schema tables are created automatically by
initDb() on server boot — no separate migration tool is required.
- Run: execute
npm run dev; API listens on http://localhost:4000.
- Verify register:
curl -s -X POST http://localhost:4000/api/auth/register -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"supersecret1","displayName":"Ada"}'.
- Verify login + cookies:
curl -s -c cookies.txt -X POST http://localhost:4000/api/auth/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"supersecret1"}' and inspect cookies.txt for access_token and refresh_token.
- Verify rotation:
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:4000/api/auth/refresh twice; the second call returns 401 TOKEN_REUSE_DETECTED because the first rotation revoked the old token.
- Verify protected route:
curl -s -b cookies.txt http://localhost:4000/api/auth/me.
- Verify logout:
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:4000/api/auth/logout, then confirm /refresh returns 401.
- Verify OAuth: open
http://localhost:4000/api/auth/google and http://localhost:4000/api/auth/github in a browser (requires configured client credentials).
- Production build: run
npm run build && npm start.
5. Edge Cases & Error Handling
- Boot refuses to start when
JWT_ACCESS_SECRET/JWT_REFRESH_SECRET are missing or shorter than 32 chars — no weak-secret default exists in production paths.
- Refresh-token rotation is atomic:
BEGIN → revoke old row → insert new row → COMMIT; any failure rolls back leaving the old token usable, and the partial-index on refresh_tokens(user_id) WHERE revoked_at IS NULL enforces a single live token per user.
- Token replay detection: presenting an already-rotated token triggers
TOKEN_REUSE_DETECTED and revokes the user's entire active token family plus a Redis refresh:denied:<jti> blacklist entry as a rollback guard.
- Redis failures do not break login/logout: the blacklist is auxiliary hardening; DB rows are the source of truth, and
rotateRefreshToken still refuses revoked rows from PostgreSQL alone.
- Password reset and email-verification tokens are stored only as SHA-256 hashes, expire after 30 min / 24 h, are single-use (
used_at), and forgot-password always returns 200 so account existence is not enumerable.
- Argon2id parameters (
m=19456, t=2, p=1) are OWASP-aligned; verifyPassword swallows malformed-hash exceptions and returns false so unknown hashes degrade to a generic "Invalid email or password" and never crash the request.
- OAuth callback failure (canceled consent, invalid state, missing profile) redirects to
<OAUTH_REDIRECT_BASE>/login and never sets partial cookies; a UNIQUE(user_id, provider) constraint prevents duplicate provider links.
- Rate limiting of login/refresh/forgot endpoints is expected in front of this service (e.g.,
express-rate-limit) — the scaffold deliberately leaves envelope and error codes standardized so a proxy or middleware can add throttling without touching the service layer.
1---2name: auth-session-oauth2-scaffolder3description: Implements comprehensive user authentication systems supporting secure cookie sessions, JWT refresh token rotation, password hashing, and third-party OAuth2 social logins.4metadata:5 source: skills/auth-session-oauth2-scaffolder/auth-session-oauth2-scaffolder.md6---78# Auth Session & OAuth2 Scaffolder910## 1. System Architecture & Prerequisites1112- Node.js >= 18 LTS (runtime), npm >= 9, TypeScript >= 5.5, `ts-node-dev@^2.0.0` for watch mode.13- Core deps: `express@^4.19.2`, `argon2@^0.41.1` (Argon2id), `jsonwebtoken@^9.0.2`, `cookie-parser@^1.4.6`, `redis@^4.7.0`, `pg@^8.12.0`, `nodemailer@^6.9.14`, `passport@^0.7.0`, `passport-google-oauth20@^2.0.0`, `passport-github2@^0.1.12`, `zod@^3.23.8`, `helmet@^7.1.0`, `cors@^2.8.5`, `dotenv@^16.4.5`.14- Infrastructure: PostgreSQL >= 14 (schema auto-created by `initDb()`), Redis >= 6 (refresh-token blacklist + optional revocation state).15- `argon2` ships prebuilt binaries via node-pre-gyp; if a native build is forced, a C++ toolchain (Visual Studio Build Tools) is required on Windows or `build-essential` on Linux.16- The JWT access secret and refresh secret are hard-required (min 32 chars) by the config loader; the server refuses to boot without them.1718## 2. Input/Output Data Contracts1920### Request payloads (JSON)2122- `POST /register` → `{"email": string(email), "password": string(8..72), "displayName": string(1..64)}` → 201 `{success, data:{user, requiresEmailVerification}}`.23- `POST /login` → `{"email": string(email), "password": string}` → 200 `{success, data:{user}}` + sets `access_token`/`refresh_token` cookies.24- `POST /refresh` (no body) → reads `refresh_token` cookie → rotates token, sets new cookies → 200.25- `POST /logout` (no body) → revokes refresh token + clears cookies → 200.26- `POST /forgot-password` → `{"email"}` → always 200 (no user enumeration).27- `POST /reset-password` → `{"token": string, "password": string(8..72)}` → 200, signs the user in.28- `POST /verify-email` → `{"token"}` → 200.29- `GET /me` (Bearer or cookie) → `{success, data:{userId, email, displayName, emailVerified}}`.3031### Cookie contract3233- `access_token`: HTTP-only, `SameSite=Strict`, `Secure` in prod, path `/`, TTL 15m.34- `refresh_token`: HTTP-only, `SameSite=Strict`, `Secure` in prod, path `/api/auth` (scoped so it is only sent to auth endpoints), TTL 7d.35- OAuth2: `GET /api/auth/google`, `GET /api/auth/github` initiate flows; `/api/auth/google/callback`, `/api/auth/github/callback` receive redirects, create/link accounts, and issue the same cookie pair.3637### Output artifact paths3839- `skills/auth-session-oauth2-scaffolder/package.json`, `tsconfig.json`, `.env.example`40- `src/config.ts`, `src/db.ts`, `src/redis.ts`, `src/mailer.ts`, `src/errors.ts`, `src/types.ts`41- `src/auth.service.ts`, `src/auth.middleware.ts`, `src/auth.routes.ts`, `src/oauth.routes.ts`, `src/server.ts`4243## 3. Production Reference Implementation4445```text46# .env.example47NODE_ENV=development48PORT=400049DATABASE_URL=postgres://postgres:postgres@localhost:5432/authdb50REDIS_URL=redis://127.0.0.1:637951CORS_ORIGIN=http://localhost:300052JWT_ACCESS_SECRET=change-me-at-least-32-chars-long-access53JWT_REFRESH_SECRET=change-me-at-least-32-chars-long-refresh54ACCESS_TOKEN_TTL=15m55REFRESH_TOKEN_TTL_SECONDS=60480056COOKIE_DOMAIN=57EMAIL_FROM=no-reply@example.com58SMTP_HOST=59SMTP_PORT=58760SMTP_USER=61SMTP_PASS=62OAUTH_REDIRECT_BASE=http://localhost:400063GOOGLE_CLIENT_ID=64GOOGLE_CLIENT_SECRET=65GITHUB_CLIENT_ID=66GITHUB_CLIENT_SECRET=67REQUIRE_EMAIL_VERIFICATION=false68```6970```json71// package.json72{73 "name": "auth-session-oauth2",74 "version": "1.0.0",75 "private": true,76 "scripts": {77 "dev": "ts-node-dev --respawn src/server.ts",78 "build": "tsc -p tsconfig.json",79 "start": "node dist/server.js"80 },81 "dependencies": {82 "argon2": "^0.41.1",83 "cookie-parser": "^1.4.6",84 "cors": "^2.8.5",85 "dotenv": "^16.4.5",86 "express": "^4.19.2",87 "helmet": "^7.1.0",88 "jsonwebtoken": "^9.0.2",89 "nodemailer": "^6.9.14",90 "passport": "^0.7.0",91 "passport-github2": "^0.1.12",92 "passport-google-oauth20": "^2.0.0",93 "pg": "^8.12.0",94 "redis": "^4.7.0",95 "zod": "^3.23.8"96 },97 "devDependencies": {98 "@types/cookie-parser": "^1.4.7",99 "@types/cors": "^2.8.17",100 "@types/express": "^4.17.21",101 "@types/jsonwebtoken": "^9.0.6",102 "@types/node": "^20.14.9",103 "@types/nodemailer": "^6.4.15",104 "@types/passport": "^1.0.16",105 "@types/passport-github2": "^1.2.8",106 "@types/passport-google-oauth20": "^2.0.16",107 "@types/pg": "^8.11.6",108 "ts-node-dev": "^2.0.0",109 "typescript": "^5.5.2"110 }111}112```113114```json115// tsconfig.json116{117 "compilerOptions": {118 "target": "ES2022",119 "module": "commonjs",120 "moduleResolution": "node",121 "outDir": "./dist",122 "rootDir": "./src",123 "strict": true,124 "esModuleInterop": true,125 "skipLibCheck": true,126 "forceConsistentCasingInFileNames": true,127 "resolveJsonModule": true,128 "sourceMap": true129 },130 "include": ["src/**/*.ts"],131 "exclude": ["node_modules", "dist"]132}133```134135```typescript136// src/types.ts137export interface User {138 id: string;139 email: string;140 password_hash: string | null;141 display_name: string;142 email_verified_at: string | null;143 created_at: string;144}145146export interface RefreshTokenRow {147 jti: string;148 user_id: string;149 rotation_count: number;150 expires_at: string;151 created_at: string;152 revoked_at: string | null;153}154155export interface AuthenticatedUser {156 userId: string;157 email: string;158 displayName: string;159}160```161162```typescript163// src/config.ts164import { z } from 'zod';165166const envSchema = z.object({167 NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),168 PORT: z.coerce.number().int().default(4000),169 DATABASE_URL: z.string().default('postgres://postgres:postgres@localhost:5432/authdb'),170 REDIS_URL: z.string().default('redis://127.0.0.1:6379'),171 CORS_ORIGIN: z.string().optional(),172 JWT_ACCESS_SECRET: z.string().min(32),173 JWT_REFRESH_SECRET: z.string().min(32),174 ACCESS_TOKEN_TTL: z.string().default('15m'),175 REFRESH_TOKEN_TTL_SECONDS: z.coerce.number().int().default(7 * 24 * 60 * 60),176 COOKIE_DOMAIN: z.string().optional(),177 EMAIL_FROM: z.string().default('no-reply@example.com'),178 SMTP_HOST: z.string().optional(),179 SMTP_PORT: z.coerce.number().int().default(587),180 SMTP_USER: z.string().optional(),181 SMTP_PASS: z.string().optional(),182 OAUTH_REDIRECT_BASE: z.string().default('http://localhost:4000'),183 GOOGLE_CLIENT_ID: z.string().optional(),184 GOOGLE_CLIENT_SECRET: z.string().optional(),185 GITHUB_CLIENT_ID: z.string().optional(),186 GITHUB_CLIENT_SECRET: z.string().optional(),187 REQUIRE_EMAIL_VERIFICATION: z.coerce.boolean().default(false)188});189190const parsed = envSchema.safeParse(process.env);191if (!parsed.success) {192 console.error('[config] invalid environment', JSON.stringify(parsed.error.flatten(), null, 2));193 process.exit(1);194}195196export interface Config extends z.infer<typeof envSchema> {197 isProd: boolean;198}199200export const config: Config = { ...parsed.data, isProd: parsed.data.NODE_ENV === 'production' };201```202203```typescript204// src/db.ts205import { Pool } from 'pg';206207export const pool = new Pool({208 connectionString: process.env.DATABASE_URL,209 max: 10,210 idleTimeoutMillis: 30_000,211 connectionTimeoutMillis: 5_000212});213214pool.on('error', (err) => {215 console.error('[pg] idle client error', err);216});217218export async function query<T extends Record<string, unknown> = Record<string, unknown>>(219 text: string,220 params: unknown[] = []221): Promise<T[]> {222 const result = await pool.query<T>(text, params);223 return result.rows;224}225226const SCHEMA = `227CREATE TABLE IF NOT EXISTS users (228 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),229 email TEXT UNIQUE NOT NULL,230 password_hash TEXT,231 display_name TEXT NOT NULL,232 email_verified_at TIMESTAMPTZ,233 created_at TIMESTAMPTZ NOT NULL DEFAULT now()234);235236CREATE TABLE IF NOT EXISTS oauth_accounts (237 provider TEXT NOT NULL,238 provider_account_id TEXT NOT NULL,239 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,240 access_token TEXT,241 refresh_token TEXT,242 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),243 PRIMARY KEY (provider, provider_account_id),244 UNIQUE (user_id, provider)245);246247CREATE TABLE IF NOT EXISTS refresh_tokens (248 jti UUID PRIMARY KEY,249 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,250 rotation_count INT NOT NULL DEFAULT 0,251 expires_at TIMESTAMPTZ NOT NULL,252 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),253 revoked_at TIMESTAMPTZ254);255256CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active257 ON refresh_tokens (user_id) WHERE revoked_at IS NULL;258259CREATE TABLE IF NOT EXISTS email_verification_tokens (260 token_hash TEXT PRIMARY KEY,261 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,262 expires_at TIMESTAMPTZ NOT NULL,263 used_at TIMESTAMPTZ264);265266CREATE TABLE IF NOT EXISTS password_reset_tokens (267 token_hash TEXT PRIMARY KEY,268 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,269 expires_at TIMESTAMPTZ NOT NULL,270 used_at TIMESTAMPTZ271);272`;273274export async function initDb(): Promise<void> {275 await pool.query(SCHEMA);276}277```278279```typescript280// src/redis.ts281import { createClient } from 'redis';282283export const redis = createClient({284 url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'285});286287redis.on('error', (err) => {288 console.error('[redis] client error', err);289});290291export async function connectRedis(): Promise<void> {292 if (!redis.isOpen) {293 await redis.connect();294 }295}296```297298```typescript299// src/mailer.ts300import nodemailer from 'nodemailer';301import { config } from './config';302303const transporter = nodemailer.createTransport({304 host: config.SMTP_HOST ?? 'localhost',305 port: config.SMTP_PORT,306 secure: config.isProd && config.SMTP_PORT === 465,307 auth: config.SMTP_USER && config.SMTP_PASS ? { user: config.SMTP_USER, pass: config.SMTP_PASS } : undefined308});309310export interface MailPayload {311 to: string;312 subject: string;313 text: string;314 html?: string;315}316317export async function sendMail(payload: MailPayload): Promise<void> {318 if (config.NODE_ENV === 'development' || config.NODE_ENV === 'test') {319 console.log('[mailer][stub]', JSON.stringify({ from: config.EMAIL_FROM, ...payload }));320 return;321 }322 await transporter.sendMail({ from: config.EMAIL_FROM, ...payload });323}324```325326```typescript327// src/errors.ts328import type { NextFunction, Request, Response } from 'express';329330export class AppError extends Error {331 public readonly statusCode: number;332 public readonly code: string;333 public readonly details?: unknown;334335 constructor(statusCode: number, code: string, message: string, details?: unknown) {336 super(message);337 Object.setPrototypeOf(this, new.target.prototype);338 this.statusCode = statusCode;339 this.code = code;340 this.details = details;341 Error.captureStackTrace(this, this.constructor);342 }343344 public static badRequest(message: string, details?: unknown): AppError {345 return new AppError(400, 'BAD_REQUEST', message, details);346 }347348 public static unauthorized(message = 'Unauthorized'): AppError {349 return new AppError(401, 'UNAUTHORIZED', message);350 }351352 public static conflict(message: string): AppError {353 return new AppError(409, 'CONFLICT', message);354 }355356 public static internal(message = 'Internal server error'): AppError {357 return new AppError(500, 'INTERNAL_ERROR', message);358 }359}360361export function notFoundHandler(req: Request, _res: Response, next: NextFunction): void {362 next(new AppError(404, 'NOT_FOUND', `Route ${req.method} ${req.originalUrl} not found`));363}364365export function errorHandler(err: unknown, _req: Request, res: Response, next: NextFunction): void {366 if (res.headersSent) {367 next(err);368 return;369 }370 if (err instanceof AppError) {371 res.status(err.statusCode).json({372 success: false,373 error: {374 code: err.code,375 message: err.message,376 ...(err.details === undefined ? {} : { details: err.details })377 }378 });379 return;380 }381 const normalized = err instanceof SyntaxError ? AppError.badRequest('Malformed JSON body') : AppError.internal();382 console.error('[error]', err);383 res.status(normalized.statusCode).json({384 success: false,385 error: { code: normalized.code, message: normalized.message }386 });387}388```389390```typescript391// src/auth.middleware.ts392import type { NextFunction, Request, Response } from 'express';393import jwt from 'jsonwebtoken';394import { config } from './config';395import { AppError } from './errors';396import type { AuthenticatedUser } from './types';397398export interface AuthenticatedRequest extends Request {399 user: AuthenticatedUser;400}401402export function extractAccessToken(req: Request): string | null {403 const header = req.headers.authorization;404 if (header && header.startsWith('Bearer ')) return header.slice(7);405 const cookie = req.cookies?.['access_token'];406 return typeof cookie === 'string' ? cookie : null;407}408409export function requireAuth(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {410 const token = extractAccessToken(req);411 if (!token) {412 return next(AppError.unauthorized('Missing access token'));413 }414415 let payload: { sub?: string; email?: string; displayName?: string; type?: string };416 try {417 payload = jwt.verify(token, config.JWT_ACCESS_SECRET) as typeof payload;418 } catch {419 return next(AppError.unauthorized('Invalid or expired access token'));420 }421422 if (payload.type !== 'access' || typeof payload.sub !== 'string') {423 return next(AppError.unauthorized('Invalid token type'));424 }425426 req.user = {427 userId: payload.sub,428 email: payload.email ?? '',429 displayName: payload.displayName ?? ''430 };431 next();432}433```434435```typescript436// src/auth.service.ts437import argon2 from 'argon2';438import jwt from 'jsonwebtoken';439import { createHash, randomBytes, randomUUID } from 'node:crypto';440import type { CookieOptions, Response } from 'express';441import { config } from './config';442import { pool, query } from './db';443import { sendMail } from './mailer';444import { redis } from './redis';445import { AppError } from './errors';446import type { AuthenticatedUser, RefreshTokenRow, User } from './types';447448export const ACCESS_COOKIE = 'access_token';449export const REFRESH_COOKIE = 'refresh_token';450451interface AccessPayload {452 sub: string;453 email: string;454 displayName: string;455 type: 'access';456}457458interface RefreshPayload {459 sub: string;460 jti: string;461 type: 'refresh';462}463464export interface Session {465 accessToken: string;466 refreshToken: string;467 user: AuthenticatedUser;468}469470function toAuthenticatedUser(user: User): AuthenticatedUser {471 return { userId: user.id, email: user.email, displayName: user.display_name };472}473474function signAccessToken(user: AuthenticatedUser): string {475 return jwt.sign(476 { sub: user.userId, email: user.email, displayName: user.displayName, type: 'access' },477 config.JWT_ACCESS_SECRET,478 { expiresIn: config.ACCESS_TOKEN_TTL }479 );480}481482function signRefreshToken(userId: string, jti: string): string {483 return jwt.sign(484 { sub: userId, jti, type: 'refresh' },485 config.JWT_REFRESH_SECRET,486 { expiresIn: config.REFRESH_TOKEN_TTL_SECONDS }487 );488}489490function accessCookieOptions(): CookieOptions {491 return {492 httpOnly: true,493 sameSite: 'strict',494 secure: config.isProd,495 domain: config.COOKIE_DOMAIN,496 path: '/',497 maxAge: 15 * 60 * 1000498 };499}500501function refreshCookieOptions(): CookieOptions {502 return {503 httpOnly: true,504 sameSite: 'strict',505 secure: config.isProd,506 domain: config.COOKIE_DOMAIN,507 path: '/api/auth',508 maxAge: config.REFRESH_TOKEN_TTL_SECONDS * 1000509 };510}511512export function setSessionCookies(res: Response, session: Session): void {513 res.cookie(ACCESS_COOKIE, session.accessToken, accessCookieOptions());514 res.cookie(REFRESH_COOKIE, session.refreshToken, refreshCookieOptions());515}516517export function clearSessionCookies(res: Response): void {518 res.clearCookie(ACCESS_COOKIE, accessCookieOptions());519 res.clearCookie(REFRESH_COOKIE, refreshCookieOptions());520}521522export async function hashPassword(password: string): Promise<string> {523 return argon2.hash(password, {524 type: argon2.argon2id,525 memoryCost: 19 * 1024,526 timeCost: 2,527 parallelism: 1528 });529}530531export async function verifyPassword(hash: string, password: string): Promise<boolean> {532 try {533 return await argon2.verify(hash, password);534 } catch {535 return false;536 }537}538539export async function issueSession(user: User): Promise<Session> {540 const jti = randomUUID();541 const refreshToken = signRefreshToken(user.id, jti);542 await query(543 `INSERT INTO refresh_tokens (jti, user_id, rotation_count, expires_at)544 VALUES ($1, $2, 0, now() + make_interval(secs => $3))`,545 [jti, user.id, config.REFRESH_TOKEN_TTL_SECONDS]546 );547 return {548 accessToken: signAccessToken(toAuthenticatedUser(user)),549 refreshToken,550 user: toAuthenticatedUser(user)551 };552}553554export async function register(input: {555 email: string;556 password: string;557 displayName: string;558}): Promise<AuthenticatedUser> {559 const email = input.email.trim().toLowerCase();560 const existing = await query<User>('SELECT * FROM users WHERE email = $1', [email]);561 if (existing.length > 0) throw AppError.conflict(`An account for ${email} already exists`);562 if (input.password.length < 8) throw AppError.badRequest('Password must be at least 8 characters');563564 const passwordHash = await hashPassword(input.password);565 const rows = await query<User>(566 `INSERT INTO users (email, password_hash, display_name, email_verified_at)567 VALUES ($1, $2, $3, $4)568 RETURNING *`,569 [email, passwordHash, input.displayName, config.REQUIRE_EMAIL_VERIFICATION ? null : new Date().toISOString()]570 );571 const user = rows[0];572 if (config.REQUIRE_EMAIL_VERIFICATION && user.email_verified_at === null) {573 await sendVerificationEmail(user.id, user.email);574 }575 return toAuthenticatedUser(user);576}577578export async function login(579 input: { email: string; password: string },580 res: Response581): Promise<AuthenticatedUser> {582 const email = input.email.trim().toLowerCase();583 const rows = await query<User>('SELECT * FROM users WHERE email = $1', [email]);584 const user = rows[0];585 if (!user || user.password_hash === null) throw AppError.unauthorized('Invalid email or password');586587 const valid = await verifyPassword(user.password_hash, input.password);588 if (!valid) throw AppError.unauthorized('Invalid email or password');589590 if (config.REQUIRE_EMAIL_VERIFICATION && user.email_verified_at === null) {591 throw AppError.unauthorized('Email not verified. Check your inbox.');592 }593594 setSessionCookies(res, await issueSession(user));595 return toAuthenticatedUser(user);596}597598export async function logout(refreshToken: string | undefined, res: Response): Promise<void> {599 if (refreshToken) {600 try {601 const payload = jwt.verify(refreshToken, config.JWT_REFRESH_SECRET) as RefreshPayload;602 await query('UPDATE refresh_tokens SET revoked_at = now() WHERE jti = $1', [payload.jti]);603 await redis.set(`refresh:denied:${payload.jti}`, '1', { EX: config.REFRESH_TOKEN_TTL_SECONDS });604 } catch {605 // Unknown or expired token: nothing to revoke.606 }607 }608 clearSessionCookies(res);609}610611export async function rotateRefreshToken(refreshToken: string, res: Response): Promise<AuthenticatedUser> {612 let payload: RefreshPayload;613 try {614 payload = jwt.verify(refreshToken, config.JWT_REFRESH_SECRET) as RefreshPayload;615 } catch {616 throw AppError.unauthorized('Invalid or expired refresh token');617 }618 if (payload.type !== 'refresh') throw AppError.unauthorized('Invalid token type');619620 const blacklisted = await redis.get(`refresh:denied:${payload.jti}`);621 if (blacklisted === '1') throw AppError.unauthorized('Refresh token revoked');622623 const rows = await query<RefreshTokenRow>('SELECT * FROM refresh_tokens WHERE jti = $1', [payload.jti]);624 const row = rows[0];625 if (!row) throw AppError.unauthorized('Unknown refresh token');626627 if (row.revoked_at !== null) {628 await query('UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL', [row.user_id]);629 await redis.set(`refresh:denied:${payload.jti}`, '1', { EX: config.REFRESH_TOKEN_TTL_SECONDS });630 throw new AppError(401, 'TOKEN_REUSE_DETECTED', 'Refresh token reuse detected; session revoked');631 }632633 if (new Date(row.expires_at).getTime() < Date.now()) {634 throw AppError.unauthorized('Refresh token expired');635 }636637 const client = await pool.connect();638 try {639 await client.query('BEGIN');640 await client.query('UPDATE refresh_tokens SET revoked_at = now() WHERE jti = $1', [row.jti]);641 const jti = randomUUID();642 await client.query(643 `INSERT INTO refresh_tokens (jti, user_id, rotation_count, expires_at)644 VALUES ($1, $2, $3, now() + make_interval(secs => $4))`,645 [jti, row.user_id, row.rotation_count + 1, config.REFRESH_TOKEN_TTL_SECONDS]646 );647 await client.query('COMMIT');648 } catch (err) {649 await client.query('ROLLBACK');650 throw err;651 } finally {652 client.release();653 }654655 const userRows = await query<User>('SELECT * FROM users WHERE id = $1', [row.user_id]);656 const user = userRows[0];657 if (!user) throw AppError.unauthorized('Account no longer exists');658 setSessionCookies(res, await issueSession(user));659 return toAuthenticatedUser(user);660}661662async function sendVerificationEmail(userId: string, email: string): Promise<void> {663 const token = randomBytes(24).toString('base64url');664 const tokenHash = createHash('sha256').update(token).digest('hex');665 await query(666 `INSERT INTO email_verification_tokens (token_hash, user_id, expires_at)667 VALUES ($1, $2, now() + interval '24 hours')`,668 [tokenHash, userId]669 );670 await sendMail({671 to: email,672 subject: 'Verify your email',673 text: `Verify your email: ${config.OAUTH_REDIRECT_BASE}/verify-email?token=${token}`674 });675}676677export async function verifyEmail(token: string): Promise<void> {678 const tokenHash = createHash('sha256').update(token).digest('hex');679 const rows = await query<{ token_hash: string; user_id: string; expires_at: string; used_at: string | null }>(680 'SELECT * FROM email_verification_tokens WHERE token_hash = $1',681 [tokenHash]682 );683 const row = rows[0];684 if (!row) throw AppError.badRequest('Invalid verification token');685 if (row.used_at !== null) throw AppError.badRequest('Verification token already used');686 if (new Date(row.expires_at).getTime() < Date.now()) throw AppError.badRequest('Verification token expired');687 await query('UPDATE users SET email_verified_at = now() WHERE id = $1', [row.user_id]);688 await query('UPDATE email_verification_tokens SET used_at = now() WHERE token_hash = $1', [tokenHash]);689}690691export async function forgotPassword(email: string): Promise<void> {692 const rows = await query<User>('SELECT * FROM users WHERE email = $1', [email.trim().toLowerCase()]);693 const user = rows[0];694 if (!user || user.password_hash === null) return;695 const token = randomBytes(32).toString('base64url');696 const tokenHash = createHash('sha256').update(token).digest('hex');697 await query(698 `INSERT INTO password_reset_tokens (token_hash, user_id, expires_at)699 VALUES ($1, $2, now() + interval '30 minutes')`,700 [tokenHash, user.id]701 );702 await sendMail({703 to: user.email,704 subject: 'Password reset',705 text: `Reset your password: ${config.OAUTH_REDIRECT_BASE}/reset-password?token=${token}`706 });707}708709export async function resetPassword(710 token: string,711 newPassword: string,712 res: Response713): Promise<AuthenticatedUser> {714 const tokenHash = createHash('sha256').update(token).digest('hex');715 const rows = await query<{ token_hash: string; user_id: string; expires_at: string; used_at: string | null }>(716 'SELECT * FROM password_reset_tokens WHERE token_hash = $1',717 [tokenHash]718 );719 const row = rows[0];720 if (!row) throw AppError.badRequest('Invalid reset token');721 if (row.used_at !== null) throw AppError.badRequest('Reset token already used');722 if (new Date(row.expires_at).getTime() < Date.now()) throw AppError.badRequest('Reset token expired');723 if (newPassword.length < 8) throw AppError.badRequest('Password must be at least 8 characters');724725 const passwordHash = await hashPassword(newPassword);726727 const client = await pool.connect();728 try {729 await client.query('BEGIN');730 await client.query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, row.user_id]);731 await client.query('UPDATE password_reset_tokens SET used_at = now() WHERE token_hash = $1', [tokenHash]);732 await client.query(733 'UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',734 [row.user_id]735 );736 await client.query('COMMIT');737 } catch (err) {738 await client.query('ROLLBACK');739 throw err;740 } finally {741 client.release();742 }743744 const userRows = await query<User>('SELECT * FROM users WHERE id = $1', [row.user_id]);745 const user = userRows[0];746 setSessionCookies(res, await issueSession(user));747 return toAuthenticatedUser(user);748}749750export async function getProfile(751 userId: string752): Promise<{ userId: string; email: string; displayName: string; emailVerified: boolean }> {753 const rows = await query<User>('SELECT * FROM users WHERE id = $1', [userId]);754 const user = rows[0];755 if (!user) throw AppError.unauthorized('Account no longer exists');756 return {757 userId: user.id,758 email: user.email,759 displayName: user.display_name,760 emailVerified: user.email_verified_at !== null761 };762}763```764765```typescript766// src/auth.routes.ts767import { Router } from 'express';768import type { NextFunction, Request, Response } from 'express';769import { z } from 'zod';770import { config } from './config';771import { AppError } from './errors';772import { requireAuth, type AuthenticatedRequest } from './auth.middleware';773import * as authService from './auth.service';774775const router = Router();776777function validate<T>(schema: z.ZodSchema<T>) {778 return (req: Request, _res: Response, next: NextFunction): void => {779 const result = schema.safeParse(req.body ?? {});780 if (!result.success) {781 return next(782 AppError.badRequest(783 'Validation failed',784 result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message }))785 )786 );787 }788 req.body = result.data;789 next();790 };791}792793const registerSchema = z.object({794 email: z.string().email(),795 password: z.string().min(8).max(72),796 displayName: z.string().min(1).max(64)797});798799const loginSchema = z.object({800 email: z.string().email(),801 password: z.string().min(1)802});803804const forgotSchema = z.object({ email: z.string().email() });805806const resetSchema = z.object({807 token: z.string().min(1),808 password: z.string().min(8).max(72)809});810811const tokenSchema = z.object({ token: z.string().min(1) });812813router.post('/register', validate(registerSchema), (req, res, next) => {814 authService815 .register(req.body as { email: string; password: string; displayName: string })816 .then((user) => {817 res.status(201).json({818 success: true,819 data: { user, requiresEmailVerification: config.REQUIRE_EMAIL_VERIFICATION }820 });821 })822 .catch(next);823});824825router.post('/login', validate(loginSchema), (req, res, next) => {826 authService827 .login(req.body as { email: string; password: string }, res)828 .then((user) => res.status(200).json({ success: true, data: { user } }))829 .catch(next);830});831832router.post('/refresh', (req, res, next) => {833 const token = req.cookies?.[authService.REFRESH_COOKIE];834 if (typeof token !== 'string') return next(AppError.unauthorized('Missing refresh token'));835 authService836 .rotateRefreshToken(token, res)837 .then((user) => res.status(200).json({ success: true, data: { user } }))838 .catch(next);839});840841router.post('/logout', (req, res, next) => {842 const token = req.cookies?.[authService.REFRESH_COOKIE];843 authService844 .logout(typeof token === 'string' ? token : undefined, res)845 .then(() => res.status(200).json({ success: true, data: null }))846 .catch(next);847});848849router.post('/forgot-password', validate(forgotSchema), (req, res, next) => {850 authService851 .forgotPassword((req.body as { email: string }).email)852 .then(() => res.status(200).json({ success: true, data: null }))853 .catch(next);854});855856router.post('/reset-password', validate(resetSchema), (req, res, next) => {857 const { token, password } = req.body as { token: string; password: string };858 authService859 .resetPassword(token, password, res)860 .then((user) => res.status(200).json({ success: true, data: { user } }))861 .catch(next);862});863864router.post('/verify-email', validate(tokenSchema), (req, res, next) => {865 authService866 .verifyEmail((req.body as { token: string }).token)867 .then(() => res.status(200).json({ success: true, data: null }))868 .catch(next);869});870871router.get('/me', requireAuth, (req: AuthenticatedRequest, res, next) => {872 authService873 .getProfile(req.user.userId)874 .then((profile) => res.status(200).json({ success: true, data: profile }))875 .catch(next);876});877878export default router;879```880881```typescript882// src/oauth.routes.ts883import { Router } from 'express';884import type { NextFunction, Request, Response } from 'express';885import passport from 'passport';886import { Strategy as GoogleStrategy, type Profile as GoogleProfile } from 'passport-google-oauth20';887import { Strategy as GitHubStrategy, type Profile as GitHubProfile } from 'passport-github2';888import { config } from './config';889import { query } from './db';890import { AppError } from './errors';891import { issueSession, setSessionCookies } from './auth.service';892import type { User } from './types';893894const router = Router();895896passport.use(897 new GoogleStrategy(898 {899 clientID: config.GOOGLE_CLIENT_ID ?? 'unset',900 clientSecret: config.GOOGLE_CLIENT_SECRET ?? 'unset',901 callbackURL: `${config.OAUTH_REDIRECT_BASE}/api/auth/google/callback`902 },903 (_accessToken, _refreshToken, profile, done) => {904 done(null, profile);905 }906 )907);908909passport.use(910 new GitHubStrategy(911 {912 clientID: config.GITHUB_CLIENT_ID ?? 'unset',913 clientSecret: config.GITHUB_CLIENT_SECRET ?? 'unset',914 callbackURL: `${config.OAUTH_REDIRECT_BASE}/api/auth/github/callback`915 },916 (_accessToken, _refreshToken, profile, done) => {917 done(null, profile);918 }919 )920);921922async function findOrCreateOAuthUser(923 provider: 'google' | 'github',924 providerAccountId: string,925 email: string | null,926 displayName: string927): Promise<User> {928 const links = await query<{ user_id: string }>(929 'SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_account_id = $2',930 [provider, providerAccountId]931 );932 if (links.length > 0) {933 const users = await query<User>('SELECT * FROM users WHERE id = $1', [links[0].user_id]);934 if (users.length > 0) return users[0];935 }936937 let user: User | undefined;938 if (email) {939 const byEmail = await query<User>('SELECT * FROM users WHERE email = $1', [email.toLowerCase()]);940 user = byEmail[0];941 }942943 if (!user) {944 const created = await query<User>(945 `INSERT INTO users (email, password_hash, display_name, email_verified_at)946 VALUES ($1, NULL, $2, $3)947 RETURNING *`,948 [email ?? `${providerAccountId}@${provider}.local`, displayName, email ? new Date().toISOString() : null]949 );950 user = created[0];951 }952953 await query(954 `INSERT INTO oauth_accounts (provider, provider_account_id, user_id, access_token, refresh_token)955 VALUES ($1, $2, $3, $4, $5)956 ON CONFLICT (provider, provider_account_id) DO NOTHING`,957 [provider, providerAccountId, user.id, null, null]958 );959 return user;960}961962function oauthCallback(provider: 'google' | 'github') {963 return (req: Request, res: Response, next: NextFunction): void => {964 const profile = req.user as GoogleProfile | GitHubProfile;965 if (!profile) {966 return next(AppError.unauthorized('OAuth callback without profile'));967 }968 const email = profile.emails?.[0]?.value ?? null;969 const username = 'username' in profile ? profile.username ?? undefined : undefined;970 const displayName = profile.displayName ?? username ?? 'OAuth User';971972 findOrCreateOAuthUser(provider, profile.id, email, displayName)973 .then(async (user) => {974 setSessionCookies(res, await issueSession(user));975 res.redirect(config.OAUTH_REDIRECT_BASE);976 })977 .catch(next);978 };979}980981router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'], session: false }));982router.get(983 '/google/callback',984 passport.authenticate('google', { session: false, failureRedirect: `${config.OAUTH_REDIRECT_BASE}/login` }),985 oauthCallback('google')986);987router.get('/github', passport.authenticate('github', { session: false }));988router.get(989 '/github/callback',990 passport.authenticate('github', { session: false, failureRedirect: `${config.OAUTH_REDIRECT_BASE}/login` }),991 oauthCallback('github')992);993994export default router;995```996997```typescript998// src/server.ts999import 'dotenv/config';1000import type { Server } from 'node:http';1001import express from 'express';1002import cookieParser from 'cookie-parser';1003import cors from 'cors';1004import helmet from 'helmet';1005import { config } from './config';1006import { initDb } from './db';1007import { connectRedis } from './redis';1008import { errorHandler, notFoundHandler } from './errors';1009import authRouter from './auth.routes';1010import oauthRouter from './oauth.routes';10111012export async function startServer(): Promise<Server> {1013 await Promise.all([initDb(), connectRedis()]);10141015 const app = express();1016 app.set('trust proxy', config.isProd ? 1 : 0);1017 app.use(helmet());1018 app.use(cors({ origin: config.CORS_ORIGIN ?? '*', credentials: true }));1019 app.use(express.json({ limit: '256kb' }));1020 app.use(cookieParser());10211022 app.get('/health', (_req, res) => {1023 res.status(200).json({ success: true, data: { status: 'ok' } });1024 });10251026 app.use('/api/auth', authRouter);1027 app.use('/api/auth', oauthRouter);10281029 app.use(notFoundHandler);1030 app.use(errorHandler);10311032 return new Promise((resolve) => {1033 const server = app.listen(config.PORT, () => {1034 console.log(`[auth] api listening on http://localhost:${config.PORT}`);1035 resolve(server);1036 });1037 });1038}10391040if (require.main === module) {1041 startServer().catch((err) => {1042 console.error('[auth] startup failed', err);1043 process.exit(1);1044 });1045}1046```10471048## 4. Execution Protocol & Step-by-Step Workflow104910501. Scaffold: create the project directory and copy all files from section 3 (package.json, tsconfig.json, `.env.example`, `src/*`).10512. Provision infra: `docker run -d --name authdb -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=authdb postgres:16` and `docker run -d --name authredis -p 6379:6379 redis:7`.10523. Configure: copy `.env.example` to `.env` and replace both `JWT_*_SECRET` values with `openssl rand -hex 32` output; fill OAuth client ids/secrets if Google/GitHub flows will be used.10534. Install: run `npm install`.10545. Migrate: schema tables are created automatically by `initDb()` on server boot — no separate migration tool is required.10556. Run: execute `npm run dev`; API listens on `http://localhost:4000`.10567. Verify register: `curl -s -X POST http://localhost:4000/api/auth/register -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"supersecret1","displayName":"Ada"}'`.10578. Verify login + cookies: `curl -s -c cookies.txt -X POST http://localhost:4000/api/auth/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"supersecret1"}'` and inspect `cookies.txt` for `access_token` and `refresh_token`.10589. Verify rotation: `curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:4000/api/auth/refresh` twice; the second call returns `401 TOKEN_REUSE_DETECTED` because the first rotation revoked the old token.105910. Verify protected route: `curl -s -b cookies.txt http://localhost:4000/api/auth/me`.106011. Verify logout: `curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:4000/api/auth/logout`, then confirm `/refresh` returns `401`.106112. Verify OAuth: open `http://localhost:4000/api/auth/google` and `http://localhost:4000/api/auth/github` in a browser (requires configured client credentials).106213. Production build: run `npm run build && npm start`.10631064## 5. Edge Cases & Error Handling10651066- Boot refuses to start when `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` are missing or shorter than 32 chars — no weak-secret default exists in production paths.1067- Refresh-token rotation is atomic: `BEGIN` → revoke old row → insert new row → `COMMIT`; any failure rolls back leaving the old token usable, and the partial-index on `refresh_tokens(user_id) WHERE revoked_at IS NULL` enforces a single live token per user.1068- Token replay detection: presenting an already-rotated token triggers `TOKEN_REUSE_DETECTED` and revokes the user's entire active token family plus a Redis `refresh:denied:<jti>` blacklist entry as a rollback guard.1069- Redis failures do not break login/logout: the blacklist is auxiliary hardening; DB rows are the source of truth, and `rotateRefreshToken` still refuses revoked rows from PostgreSQL alone.1070- Password reset and email-verification tokens are stored only as SHA-256 hashes, expire after 30 min / 24 h, are single-use (`used_at`), and `forgot-password` always returns 200 so account existence is not enumerable.1071- Argon2id parameters (`m=19456, t=2, p=1`) are OWASP-aligned; `verifyPassword` swallows malformed-hash exceptions and returns `false` so unknown hashes degrade to a generic "Invalid email or password" and never crash the request.1072- OAuth callback failure (canceled consent, invalid state, missing profile) redirects to `<OAUTH_REDIRECT_BASE>/login` and never sets partial cookies; a `UNIQUE(user_id, provider)` constraint prevents duplicate provider links.1073- Rate limiting of login/refresh/forgot endpoints is expected in front of this service (e.g., `express-rate-limit`) — the scaffold deliberately leaves envelope and error codes standardized so a proxy or middleware can add throttling without touching the service layer.