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.4---56# Auth Session & OAuth2 Scaffolder78## 1. System Architecture & Prerequisites910- Node.js >= 18 LTS (runtime), npm >= 9, TypeScript >= 5.5, `ts-node-dev@^2.0.0` for watch mode.11- 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`.12- Infrastructure: PostgreSQL >= 14 (schema auto-created by `initDb()`), Redis >= 6 (refresh-token blacklist + optional revocation state).13- `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.14- The JWT access secret and refresh secret are hard-required (min 32 chars) by the config loader; the server refuses to boot without them.1516## 2. Input/Output Data Contracts1718### Request payloads (JSON)1920- `POST /register` → `{"email": string(email), "password": string(8..72), "displayName": string(1..64)}` → 201 `{success, data:{user, requiresEmailVerification}}`.21- `POST /login` → `{"email": string(email), "password": string}` → 200 `{success, data:{user}}` + sets `access_token`/`refresh_token` cookies.22- `POST /refresh` (no body) → reads `refresh_token` cookie → rotates token, sets new cookies → 200.23- `POST /logout` (no body) → revokes refresh token + clears cookies → 200.24- `POST /forgot-password` → `{"email"}` → always 200 (no user enumeration).25- `POST /reset-password` → `{"token": string, "password": string(8..72)}` → 200, signs the user in.26- `POST /verify-email` → `{"token"}` → 200.27- `GET /me` (Bearer or cookie) → `{success, data:{userId, email, displayName, emailVerified}}`.2829### Cookie contract3031- `access_token`: HTTP-only, `SameSite=Strict`, `Secure` in prod, path `/`, TTL 15m.32- `refresh_token`: HTTP-only, `SameSite=Strict`, `Secure` in prod, path `/api/auth` (scoped so it is only sent to auth endpoints), TTL 7d.33- 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.3435### Output artifact paths3637- `skills/auth-session-oauth2-scaffolder/package.json`, `tsconfig.json`, `.env.example`38- `src/config.ts`, `src/db.ts`, `src/redis.ts`, `src/mailer.ts`, `src/errors.ts`, `src/types.ts`39- `src/auth.service.ts`, `src/auth.middleware.ts`, `src/auth.routes.ts`, `src/oauth.routes.ts`, `src/server.ts`4041## 3. Production Reference Implementation4243```text44# .env.example45NODE_ENV=development46PORT=400047DATABASE_URL=postgres://postgres:postgres@localhost:5432/authdb48REDIS_URL=redis://127.0.0.1:637949CORS_ORIGIN=http://localhost:300050JWT_ACCESS_SECRET=change-me-at-least-32-chars-long-access51JWT_REFRESH_SECRET=change-me-at-least-32-chars-long-refresh52ACCESS_TOKEN_TTL=15m53REFRESH_TOKEN_TTL_SECONDS=60480054COOKIE_DOMAIN=55EMAIL_FROM=no-reply@example.com56SMTP_HOST=57SMTP_PORT=58758SMTP_USER=59SMTP_PASS=60OAUTH_REDIRECT_BASE=http://localhost:400061GOOGLE_CLIENT_ID=62GOOGLE_CLIENT_SECRET=63GITHUB_CLIENT_ID=64GITHUB_CLIENT_SECRET=65REQUIRE_EMAIL_VERIFICATION=false66```6768```json69// package.json70{71 "name": "auth-session-oauth2",72 "version": "1.0.0",73 "private": true,74 "scripts": {75 "dev": "ts-node-dev --respawn src/server.ts",76 "build": "tsc -p tsconfig.json",77 "start": "node dist/server.js"78 },79 "dependencies": {80 "argon2": "^0.41.1",81 "cookie-parser": "^1.4.6",82 "cors": "^2.8.5",83 "dotenv": "^16.4.5",84 "express": "^4.19.2",85 "helmet": "^7.1.0",86 "jsonwebtoken": "^9.0.2",87 "nodemailer": "^6.9.14",88 "passport": "^0.7.0",89 "passport-github2": "^0.1.12",90 "passport-google-oauth20": "^2.0.0",91 "pg": "^8.12.0",92 "redis": "^4.7.0",93 "zod": "^3.23.8"94 },95 "devDependencies": {96 "@types/cookie-parser": "^1.4.7",97 "@types/cors": "^2.8.17",98 "@types/express": "^4.17.21",99 "@types/jsonwebtoken": "^9.0.6",100 "@types/node": "^20.14.9",101 "@types/nodemailer": "^6.4.15",102 "@types/passport": "^1.0.16",103 "@types/passport-github2": "^1.2.8",104 "@types/passport-google-oauth20": "^2.0.16",105 "@types/pg": "^8.11.6",106 "ts-node-dev": "^2.0.0",107 "typescript": "^5.5.2"108 }109}110```111112```json113// tsconfig.json114{115 "compilerOptions": {116 "target": "ES2022",117 "module": "commonjs",118 "moduleResolution": "node",119 "outDir": "./dist",120 "rootDir": "./src",121 "strict": true,122 "esModuleInterop": true,123 "skipLibCheck": true,124 "forceConsistentCasingInFileNames": true,125 "resolveJsonModule": true,126 "sourceMap": true127 },128 "include": ["src/**/*.ts"],129 "exclude": ["node_modules", "dist"]130}131```132133```typescript134// src/types.ts135export interface User {136 id: string;137 email: string;138 password_hash: string | null;139 display_name: string;140 email_verified_at: string | null;141 created_at: string;142}143144export interface RefreshTokenRow {145 jti: string;146 user_id: string;147 rotation_count: number;148 expires_at: string;149 created_at: string;150 revoked_at: string | null;151}152153export interface AuthenticatedUser {154 userId: string;155 email: string;156 displayName: string;157}158```159160```typescript161// src/config.ts162import { z } from 'zod';163164const envSchema = z.object({165 NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),166 PORT: z.coerce.number().int().default(4000),167 DATABASE_URL: z.string().default('postgres://postgres:postgres@localhost:5432/authdb'),168 REDIS_URL: z.string().default('redis://127.0.0.1:6379'),169 CORS_ORIGIN: z.string().optional(),170 JWT_ACCESS_SECRET: z.string().min(32),171 JWT_REFRESH_SECRET: z.string().min(32),172 ACCESS_TOKEN_TTL: z.string().default('15m'),173 REFRESH_TOKEN_TTL_SECONDS: z.coerce.number().int().default(7 * 24 * 60 * 60),174 COOKIE_DOMAIN: z.string().optional(),175 EMAIL_FROM: z.string().default('no-reply@example.com'),176 SMTP_HOST: z.string().optional(),177 SMTP_PORT: z.coerce.number().int().default(587),178 SMTP_USER: z.string().optional(),179 SMTP_PASS: z.string().optional(),180 OAUTH_REDIRECT_BASE: z.string().default('http://localhost:4000'),181 GOOGLE_CLIENT_ID: z.string().optional(),182 GOOGLE_CLIENT_SECRET: z.string().optional(),183 GITHUB_CLIENT_ID: z.string().optional(),184 GITHUB_CLIENT_SECRET: z.string().optional(),185 REQUIRE_EMAIL_VERIFICATION: z.coerce.boolean().default(false)186});187188const parsed = envSchema.safeParse(process.env);189if (!parsed.success) {190 console.error('[config] invalid environment', JSON.stringify(parsed.error.flatten(), null, 2));191 process.exit(1);192}193194export interface Config extends z.infer<typeof envSchema> {195 isProd: boolean;196}197198export const config: Config = { ...parsed.data, isProd: parsed.data.NODE_ENV === 'production' };199```200201```typescript202// src/db.ts203import { Pool } from 'pg';204205export const pool = new Pool({206 connectionString: process.env.DATABASE_URL,207 max: 10,208 idleTimeoutMillis: 30_000,209 connectionTimeoutMillis: 5_000210});211212pool.on('error', (err) => {213 console.error('[pg] idle client error', err);214});215216export async function query<T extends Record<string, unknown> = Record<string, unknown>>(217 text: string,218 params: unknown[] = []219): Promise<T[]> {220 const result = await pool.query<T>(text, params);221 return result.rows;222}223224const SCHEMA = `225CREATE TABLE IF NOT EXISTS users (226 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),227 email TEXT UNIQUE NOT NULL,228 password_hash TEXT,229 display_name TEXT NOT NULL,230 email_verified_at TIMESTAMPTZ,231 created_at TIMESTAMPTZ NOT NULL DEFAULT now()232);233234CREATE TABLE IF NOT EXISTS oauth_accounts (235 provider TEXT NOT NULL,236 provider_account_id TEXT NOT NULL,237 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,238 access_token TEXT,239 refresh_token TEXT,240 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),241 PRIMARY KEY (provider, provider_account_id),242 UNIQUE (user_id, provider)243);244245CREATE TABLE IF NOT EXISTS refresh_tokens (246 jti UUID PRIMARY KEY,247 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,248 rotation_count INT NOT NULL DEFAULT 0,249 expires_at TIMESTAMPTZ NOT NULL,250 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),251 revoked_at TIMESTAMPTZ252);253254CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active255 ON refresh_tokens (user_id) WHERE revoked_at IS NULL;256257CREATE TABLE IF NOT EXISTS email_verification_tokens (258 token_hash TEXT PRIMARY KEY,259 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,260 expires_at TIMESTAMPTZ NOT NULL,261 used_at TIMESTAMPTZ262);263264CREATE TABLE IF NOT EXISTS password_reset_tokens (265 token_hash TEXT PRIMARY KEY,266 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,267 expires_at TIMESTAMPTZ NOT NULL,268 used_at TIMESTAMPTZ269);270`;271272export async function initDb(): Promise<void> {273 await pool.query(SCHEMA);274}275```276277```typescript278// src/redis.ts279import { createClient } from 'redis';280281export const redis = createClient({282 url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'283});284285redis.on('error', (err) => {286 console.error('[redis] client error', err);287});288289export async function connectRedis(): Promise<void> {290 if (!redis.isOpen) {291 await redis.connect();292 }293}294```295296```typescript297// src/mailer.ts298import nodemailer from 'nodemailer';299import { config } from './config';300301const transporter = nodemailer.createTransport({302 host: config.SMTP_HOST ?? 'localhost',303 port: config.SMTP_PORT,304 secure: config.isProd && config.SMTP_PORT === 465,305 auth: config.SMTP_USER && config.SMTP_PASS ? { user: config.SMTP_USER, pass: config.SMTP_PASS } : undefined306});307308export interface MailPayload {309 to: string;310 subject: string;311 text: string;312 html?: string;313}314315export async function sendMail(payload: MailPayload): Promise<void> {316 if (config.NODE_ENV === 'development' || config.NODE_ENV === 'test') {317 console.log('[mailer][stub]', JSON.stringify({ from: config.EMAIL_FROM, ...payload }));318 return;319 }320 await transporter.sendMail({ from: config.EMAIL_FROM, ...payload });321}322```323324```typescript325// src/errors.ts326import type { NextFunction, Request, Response } from 'express';327328export class AppError extends Error {329 public readonly statusCode: number;330 public readonly code: string;331 public readonly details?: unknown;332333 constructor(statusCode: number, code: string, message: string, details?: unknown) {334 super(message);335 Object.setPrototypeOf(this, new.target.prototype);336 this.statusCode = statusCode;337 this.code = code;338 this.details = details;339 Error.captureStackTrace(this, this.constructor);340 }341342 public static badRequest(message: string, details?: unknown): AppError {343 return new AppError(400, 'BAD_REQUEST', message, details);344 }345346 public static unauthorized(message = 'Unauthorized'): AppError {347 return new AppError(401, 'UNAUTHORIZED', message);348 }349350 public static conflict(message: string): AppError {351 return new AppError(409, 'CONFLICT', message);352 }353354 public static internal(message = 'Internal server error'): AppError {355 return new AppError(500, 'INTERNAL_ERROR', message);356 }357}358359export function notFoundHandler(req: Request, _res: Response, next: NextFunction): void {360 next(new AppError(404, 'NOT_FOUND', `Route ${req.method} ${req.originalUrl} not found`));361}362363export function errorHandler(err: unknown, _req: Request, res: Response, next: NextFunction): void {364 if (res.headersSent) {365 next(err);366 return;367 }368 if (err instanceof AppError) {369 res.status(err.statusCode).json({370 success: false,371 error: {372 code: err.code,373 message: err.message,374 ...(err.details === undefined ? {} : { details: err.details })375 }376 });377 return;378 }379 const normalized = err instanceof SyntaxError ? AppError.badRequest('Malformed JSON body') : AppError.internal();380 console.error('[error]', err);381 res.status(normalized.statusCode).json({382 success: false,383 error: { code: normalized.code, message: normalized.message }384 });385}386```387388```typescript389// src/auth.middleware.ts390import type { NextFunction, Request, Response } from 'express';391import jwt from 'jsonwebtoken';392import { config } from './config';393import { AppError } from './errors';394import type { AuthenticatedUser } from './types';395396export interface AuthenticatedRequest extends Request {397 user: AuthenticatedUser;398}399400export function extractAccessToken(req: Request): string | null {401 const header = req.headers.authorization;402 if (header && header.startsWith('Bearer ')) return header.slice(7);403 const cookie = req.cookies?.['access_token'];404 return typeof cookie === 'string' ? cookie : null;405}406407export function requireAuth(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {408 const token = extractAccessToken(req);409 if (!token) {410 return next(AppError.unauthorized('Missing access token'));411 }412413 let payload: { sub?: string; email?: string; displayName?: string; type?: string };414 try {415 payload = jwt.verify(token, config.JWT_ACCESS_SECRET) as typeof payload;416 } catch {417 return next(AppError.unauthorized('Invalid or expired access token'));418 }419420 if (payload.type !== 'access' || typeof payload.sub !== 'string') {421 return next(AppError.unauthorized('Invalid token type'));422 }423424 req.user = {425 userId: payload.sub,426 email: payload.email ?? '',427 displayName: payload.displayName ?? ''428 };429 next();430}431```432433```typescript434// src/auth.service.ts435import argon2 from 'argon2';436import jwt from 'jsonwebtoken';437import { createHash, randomBytes, randomUUID } from 'node:crypto';438import type { CookieOptions, Response } from 'express';439import { config } from './config';440import { pool, query } from './db';441import { sendMail } from './mailer';442import { redis } from './redis';443import { AppError } from './errors';444import type { AuthenticatedUser, RefreshTokenRow, User } from './types';445446export const ACCESS_COOKIE = 'access_token';447export const REFRESH_COOKIE = 'refresh_token';448449interface AccessPayload {450 sub: string;451 email: string;452 displayName: string;453 type: 'access';454}455456interface RefreshPayload {457 sub: string;458 jti: string;459 type: 'refresh';460}461462export interface Session {463 accessToken: string;464 refreshToken: string;465 user: AuthenticatedUser;466}467468function toAuthenticatedUser(user: User): AuthenticatedUser {469 return { userId: user.id, email: user.email, displayName: user.display_name };470}471472function signAccessToken(user: AuthenticatedUser): string {473 return jwt.sign(474 { sub: user.userId, email: user.email, displayName: user.displayName, type: 'access' },475 config.JWT_ACCESS_SECRET,476 { expiresIn: config.ACCESS_TOKEN_TTL }477 );478}479480function signRefreshToken(userId: string, jti: string): string {481 return jwt.sign(482 { sub: userId, jti, type: 'refresh' },483 config.JWT_REFRESH_SECRET,484 { expiresIn: config.REFRESH_TOKEN_TTL_SECONDS }485 );486}487488function accessCookieOptions(): CookieOptions {489 return {490 httpOnly: true,491 sameSite: 'strict',492 secure: config.isProd,493 domain: config.COOKIE_DOMAIN,494 path: '/',495 maxAge: 15 * 60 * 1000496 };497}498499function refreshCookieOptions(): CookieOptions {500 return {501 httpOnly: true,502 sameSite: 'strict',503 secure: config.isProd,504 domain: config.COOKIE_DOMAIN,505 path: '/api/auth',506 maxAge: config.REFRESH_TOKEN_TTL_SECONDS * 1000507 };508}509510export function setSessionCookies(res: Response, session: Session): void {511 res.cookie(ACCESS_COOKIE, session.accessToken, accessCookieOptions());512 res.cookie(REFRESH_COOKIE, session.refreshToken, refreshCookieOptions());513}514515export function clearSessionCookies(res: Response): void {516 res.clearCookie(ACCESS_COOKIE, accessCookieOptions());517 res.clearCookie(REFRESH_COOKIE, refreshCookieOptions());518}519520export async function hashPassword(password: string): Promise<string> {521 return argon2.hash(password, {522 type: argon2.argon2id,523 memoryCost: 19 * 1024,524 timeCost: 2,525 parallelism: 1526 });527}528529export async function verifyPassword(hash: string, password: string): Promise<boolean> {530 try {531 return await argon2.verify(hash, password);532 } catch {533 return false;534 }535}536537export async function issueSession(user: User): Promise<Session> {538 const jti = randomUUID();539 const refreshToken = signRefreshToken(user.id, jti);540 await query(541 `INSERT INTO refresh_tokens (jti, user_id, rotation_count, expires_at)542 VALUES ($1, $2, 0, now() + make_interval(secs => $3))`,543 [jti, user.id, config.REFRESH_TOKEN_TTL_SECONDS]544 );545 return {546 accessToken: signAccessToken(toAuthenticatedUser(user)),547 refreshToken,548 user: toAuthenticatedUser(user)549 };550}551552export async function register(input: {553 email: string;554 password: string;555 displayName: string;556}): Promise<AuthenticatedUser> {557 const email = input.email.trim().toLowerCase();558 const existing = await query<User>('SELECT * FROM users WHERE email = $1', [email]);559 if (existing.length > 0) throw AppError.conflict(`An account for ${email} already exists`);560 if (input.password.length < 8) throw AppError.badRequest('Password must be at least 8 characters');561562 const passwordHash = await hashPassword(input.password);563 const rows = await query<User>(564 `INSERT INTO users (email, password_hash, display_name, email_verified_at)565 VALUES ($1, $2, $3, $4)566 RETURNING *`,567 [email, passwordHash, input.displayName, config.REQUIRE_EMAIL_VERIFICATION ? null : new Date().toISOString()]568 );569 const user = rows[0];570 if (config.REQUIRE_EMAIL_VERIFICATION && user.email_verified_at === null) {571 await sendVerificationEmail(user.id, user.email);572 }573 return toAuthenticatedUser(user);574}575576export async function login(577 input: { email: string; password: string },578 res: Response579): Promise<AuthenticatedUser> {580 const email = input.email.trim().toLowerCase();581 const rows = await query<User>('SELECT * FROM users WHERE email = $1', [email]);582 const user = rows[0];583 if (!user || user.password_hash === null) throw AppError.unauthorized('Invalid email or password');584585 const valid = await verifyPassword(user.password_hash, input.password);586 if (!valid) throw AppError.unauthorized('Invalid email or password');587588 if (config.REQUIRE_EMAIL_VERIFICATION && user.email_verified_at === null) {589 throw AppError.unauthorized('Email not verified. Check your inbox.');590 }591592 setSessionCookies(res, await issueSession(user));593 return toAuthenticatedUser(user);594}595596export async function logout(refreshToken: string | undefined, res: Response): Promise<void> {597 if (refreshToken) {598 try {599 const payload = jwt.verify(refreshToken, config.JWT_REFRESH_SECRET) as RefreshPayload;600 await query('UPDATE refresh_tokens SET revoked_at = now() WHERE jti = $1', [payload.jti]);601 await redis.set(`refresh:denied:${payload.jti}`, '1', { EX: config.REFRESH_TOKEN_TTL_SECONDS });602 } catch {603 // Unknown or expired token: nothing to revoke.604 }605 }606 clearSessionCookies(res);607}608609export async function rotateRefreshToken(refreshToken: string, res: Response): Promise<AuthenticatedUser> {610 let payload: RefreshPayload;611 try {612 payload = jwt.verify(refreshToken, config.JWT_REFRESH_SECRET) as RefreshPayload;613 } catch {614 throw AppError.unauthorized('Invalid or expired refresh token');615 }616 if (payload.type !== 'refresh') throw AppError.unauthorized('Invalid token type');617618 const blacklisted = await redis.get(`refresh:denied:${payload.jti}`);619 if (blacklisted === '1') throw AppError.unauthorized('Refresh token revoked');620621 const rows = await query<RefreshTokenRow>('SELECT * FROM refresh_tokens WHERE jti = $1', [payload.jti]);622 const row = rows[0];623 if (!row) throw AppError.unauthorized('Unknown refresh token');624625 if (row.revoked_at !== null) {626 await query('UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL', [row.user_id]);627 await redis.set(`refresh:denied:${payload.jti}`, '1', { EX: config.REFRESH_TOKEN_TTL_SECONDS });628 throw new AppError(401, 'TOKEN_REUSE_DETECTED', 'Refresh token reuse detected; session revoked');629 }630631 if (new Date(row.expires_at).getTime() < Date.now()) {632 throw AppError.unauthorized('Refresh token expired');633 }634635 const client = await pool.connect();636 try {637 await client.query('BEGIN');638 await client.query('UPDATE refresh_tokens SET revoked_at = now() WHERE jti = $1', [row.jti]);639 const jti = randomUUID();640 await client.query(641 `INSERT INTO refresh_tokens (jti, user_id, rotation_count, expires_at)642 VALUES ($1, $2, $3, now() + make_interval(secs => $4))`,643 [jti, row.user_id, row.rotation_count + 1, config.REFRESH_TOKEN_TTL_SECONDS]644 );645 await client.query('COMMIT');646 } catch (err) {647 await client.query('ROLLBACK');648 throw err;649 } finally {650 client.release();651 }652653 const userRows = await query<User>('SELECT * FROM users WHERE id = $1', [row.user_id]);654 const user = userRows[0];655 if (!user) throw AppError.unauthorized('Account no longer exists');656 setSessionCookies(res, await issueSession(user));657 return toAuthenticatedUser(user);658}659660async function sendVerificationEmail(userId: string, email: string): Promise<void> {661 const token = randomBytes(24).toString('base64url');662 const tokenHash = createHash('sha256').update(token).digest('hex');663 await query(664 `INSERT INTO email_verification_tokens (token_hash, user_id, expires_at)665 VALUES ($1, $2, now() + interval '24 hours')`,666 [tokenHash, userId]667 );668 await sendMail({669 to: email,670 subject: 'Verify your email',671 text: `Verify your email: ${config.OAUTH_REDIRECT_BASE}/verify-email?token=${token}`672 });673}674675export async function verifyEmail(token: string): Promise<void> {676 const tokenHash = createHash('sha256').update(token).digest('hex');677 const rows = await query<{ token_hash: string; user_id: string; expires_at: string; used_at: string | null }>(678 'SELECT * FROM email_verification_tokens WHERE token_hash = $1',679 [tokenHash]680 );681 const row = rows[0];682 if (!row) throw AppError.badRequest('Invalid verification token');683 if (row.used_at !== null) throw AppError.badRequest('Verification token already used');684 if (new Date(row.expires_at).getTime() < Date.now()) throw AppError.badRequest('Verification token expired');685 await query('UPDATE users SET email_verified_at = now() WHERE id = $1', [row.user_id]);686 await query('UPDATE email_verification_tokens SET used_at = now() WHERE token_hash = $1', [tokenHash]);687}688689export async function forgotPassword(email: string): Promise<void> {690 const rows = await query<User>('SELECT * FROM users WHERE email = $1', [email.trim().toLowerCase()]);691 const user = rows[0];692 if (!user || user.password_hash === null) return;693 const token = randomBytes(32).toString('base64url');694 const tokenHash = createHash('sha256').update(token).digest('hex');695 await query(696 `INSERT INTO password_reset_tokens (token_hash, user_id, expires_at)697 VALUES ($1, $2, now() + interval '30 minutes')`,698 [tokenHash, user.id]699 );700 await sendMail({701 to: user.email,702 subject: 'Password reset',703 text: `Reset your password: ${config.OAUTH_REDIRECT_BASE}/reset-password?token=${token}`704 });705}706707export async function resetPassword(708 token: string,709 newPassword: string,710 res: Response711): Promise<AuthenticatedUser> {712 const tokenHash = createHash('sha256').update(token).digest('hex');713 const rows = await query<{ token_hash: string; user_id: string; expires_at: string; used_at: string | null }>(714 'SELECT * FROM password_reset_tokens WHERE token_hash = $1',715 [tokenHash]716 );717 const row = rows[0];718 if (!row) throw AppError.badRequest('Invalid reset token');719 if (row.used_at !== null) throw AppError.badRequest('Reset token already used');720 if (new Date(row.expires_at).getTime() < Date.now()) throw AppError.badRequest('Reset token expired');721 if (newPassword.length < 8) throw AppError.badRequest('Password must be at least 8 characters');722723 const passwordHash = await hashPassword(newPassword);724725 const client = await pool.connect();726 try {727 await client.query('BEGIN');728 await client.query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, row.user_id]);729 await client.query('UPDATE password_reset_tokens SET used_at = now() WHERE token_hash = $1', [tokenHash]);730 await client.query(731 'UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',732 [row.user_id]733 );734 await client.query('COMMIT');735 } catch (err) {736 await client.query('ROLLBACK');737 throw err;738 } finally {739 client.release();740 }741742 const userRows = await query<User>('SELECT * FROM users WHERE id = $1', [row.user_id]);743 const user = userRows[0];744 setSessionCookies(res, await issueSession(user));745 return toAuthenticatedUser(user);746}747748export async function getProfile(749 userId: string750): Promise<{ userId: string; email: string; displayName: string; emailVerified: boolean }> {751 const rows = await query<User>('SELECT * FROM users WHERE id = $1', [userId]);752 const user = rows[0];753 if (!user) throw AppError.unauthorized('Account no longer exists');754 return {755 userId: user.id,756 email: user.email,757 displayName: user.display_name,758 emailVerified: user.email_verified_at !== null759 };760}761```762763```typescript764// src/auth.routes.ts765import { Router } from 'express';766import type { NextFunction, Request, Response } from 'express';767import { z } from 'zod';768import { config } from './config';769import { AppError } from './errors';770import { requireAuth, type AuthenticatedRequest } from './auth.middleware';771import * as authService from './auth.service';772773const router = Router();774775function validate<T>(schema: z.ZodSchema<T>) {776 return (req: Request, _res: Response, next: NextFunction): void => {777 const result = schema.safeParse(req.body ?? {});778 if (!result.success) {779 return next(780 AppError.badRequest(781 'Validation failed',782 result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message }))783 )784 );785 }786 req.body = result.data;787 next();788 };789}790791const registerSchema = z.object({792 email: z.string().email(),793 password: z.string().min(8).max(72),794 displayName: z.string().min(1).max(64)795});796797const loginSchema = z.object({798 email: z.string().email(),799 password: z.string().min(1)800});801802const forgotSchema = z.object({ email: z.string().email() });803804const resetSchema = z.object({805 token: z.string().min(1),806 password: z.string().min(8).max(72)807});808809const tokenSchema = z.object({ token: z.string().min(1) });810811router.post('/register', validate(registerSchema), (req, res, next) => {812 authService813 .register(req.body as { email: string; password: string; displayName: string })814 .then((user) => {815 res.status(201).json({816 success: true,817 data: { user, requiresEmailVerification: config.REQUIRE_EMAIL_VERIFICATION }818 });819 })820 .catch(next);821});822823router.post('/login', validate(loginSchema), (req, res, next) => {824 authService825 .login(req.body as { email: string; password: string }, res)826 .then((user) => res.status(200).json({ success: true, data: { user } }))827 .catch(next);828});829830router.post('/refresh', (req, res, next) => {831 const token = req.cookies?.[authService.REFRESH_COOKIE];832 if (typeof token !== 'string') return next(AppError.unauthorized('Missing refresh token'));833 authService834 .rotateRefreshToken(token, res)835 .then((user) => res.status(200).json({ success: true, data: { user } }))836 .catch(next);837});838839router.post('/logout', (req, res, next) => {840 const token = req.cookies?.[authService.REFRESH_COOKIE];841 authService842 .logout(typeof token === 'string' ? token : undefined, res)843 .then(() => res.status(200).json({ success: true, data: null }))844 .catch(next);845});846847router.post('/forgot-password', validate(forgotSchema), (req, res, next) => {848 authService849 .forgotPassword((req.body as { email: string }).email)850 .then(() => res.status(200).json({ success: true, data: null }))851 .catch(next);852});853854router.post('/reset-password', validate(resetSchema), (req, res, next) => {855 const { token, password } = req.body as { token: string; password: string };856 authService857 .resetPassword(token, password, res)858 .then((user) => res.status(200).json({ success: true, data: { user } }))859 .catch(next);860});861862router.post('/verify-email', validate(tokenSchema), (req, res, next) => {863 authService864 .verifyEmail((req.body as { token: string }).token)865 .then(() => res.status(200).json({ success: true, data: null }))866 .catch(next);867});868869router.get('/me', requireAuth, (req: AuthenticatedRequest, res, next) => {870 authService871 .getProfile(req.user.userId)872 .then((profile) => res.status(200).json({ success: true, data: profile }))873 .catch(next);874});875876export default router;877```878879```typescript880// src/oauth.routes.ts881import { Router } from 'express';882import type { NextFunction, Request, Response } from 'express';883import passport from 'passport';884import { Strategy as GoogleStrategy, type Profile as GoogleProfile } from 'passport-google-oauth20';885import { Strategy as GitHubStrategy, type Profile as GitHubProfile } from 'passport-github2';886import { config } from './config';887import { query } from './db';888import { AppError } from './errors';889import { issueSession, setSessionCookies } from './auth.service';890import type { User } from './types';891892const router = Router();893894passport.use(895 new GoogleStrategy(896 {897 clientID: config.GOOGLE_CLIENT_ID ?? 'unset',898 clientSecret: config.GOOGLE_CLIENT_SECRET ?? 'unset',899 callbackURL: `${config.OAUTH_REDIRECT_BASE}/api/auth/google/callback`900 },901 (_accessToken, _refreshToken, profile, done) => {902 done(null, profile);903 }904 )905);906907passport.use(908 new GitHubStrategy(909 {910 clientID: config.GITHUB_CLIENT_ID ?? 'unset',911 clientSecret: config.GITHUB_CLIENT_SECRET ?? 'unset',912 callbackURL: `${config.OAUTH_REDIRECT_BASE}/api/auth/github/callback`913 },914 (_accessToken, _refreshToken, profile, done) => {915 done(null, profile);916 }917 )918);919920async function findOrCreateOAuthUser(921 provider: 'google' | 'github',922 providerAccountId: string,923 email: string | null,924 displayName: string925): Promise<User> {926 const links = await query<{ user_id: string }>(927 'SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_account_id = $2',928 [provider, providerAccountId]929 );930 if (links.length > 0) {931 const users = await query<User>('SELECT * FROM users WHERE id = $1', [links[0].user_id]);932 if (users.length > 0) return users[0];933 }934935 let user: User | undefined;936 if (email) {937 const byEmail = await query<User>('SELECT * FROM users WHERE email = $1', [email.toLowerCase()]);938 user = byEmail[0];939 }940941 if (!user) {942 const created = await query<User>(943 `INSERT INTO users (email, password_hash, display_name, email_verified_at)944 VALUES ($1, NULL, $2, $3)945 RETURNING *`,946 [email ?? `${providerAccountId}@${provider}.local`, displayName, email ? new Date().toISOString() : null]947 );948 user = created[0];949 }950951 await query(952 `INSERT INTO oauth_accounts (provider, provider_account_id, user_id, access_token, refresh_token)953 VALUES ($1, $2, $3, $4, $5)954 ON CONFLICT (provider, provider_account_id) DO NOTHING`,955 [provider, providerAccountId, user.id, null, null]956 );957 return user;958}959960function oauthCallback(provider: 'google' | 'github') {961 return (req: Request, res: Response, next: NextFunction): void => {962 const profile = req.user as GoogleProfile | GitHubProfile;963 if (!profile) {964 return next(AppError.unauthorized('OAuth callback without profile'));965 }966 const email = profile.emails?.[0]?.value ?? null;967 const username = 'username' in profile ? profile.username ?? undefined : undefined;968 const displayName = profile.displayName ?? username ?? 'OAuth User';969970 findOrCreateOAuthUser(provider, profile.id, email, displayName)971 .then(async (user) => {972 setSessionCookies(res, await issueSession(user));973 res.redirect(config.OAUTH_REDIRECT_BASE);974 })975 .catch(next);976 };977}978979router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'], session: false }));980router.get(981 '/google/callback',982 passport.authenticate('google', { session: false, failureRedirect: `${config.OAUTH_REDIRECT_BASE}/login` }),983 oauthCallback('google')984);985router.get('/github', passport.authenticate('github', { session: false }));986router.get(987 '/github/callback',988 passport.authenticate('github', { session: false, failureRedirect: `${config.OAUTH_REDIRECT_BASE}/login` }),989 oauthCallback('github')990);991992export default router;993```994995```typescript996// src/server.ts997import 'dotenv/config';998import type { Server } from 'node:http';999import express from 'express';1000import cookieParser from 'cookie-parser';1001import cors from 'cors';1002import helmet from 'helmet';1003import { config } from './config';1004import { initDb } from './db';1005import { connectRedis } from './redis';1006import { errorHandler, notFoundHandler } from './errors';1007import authRouter from './auth.routes';1008import oauthRouter from './oauth.routes';10091010export async function startServer(): Promise<Server> {1011 await Promise.all([initDb(), connectRedis()]);10121013 const app = express();1014 app.set('trust proxy', config.isProd ? 1 : 0);1015 app.use(helmet());1016 app.use(cors({ origin: config.CORS_ORIGIN ?? '*', credentials: true }));1017 app.use(express.json({ limit: '256kb' }));1018 app.use(cookieParser());10191020 app.get('/health', (_req, res) => {1021 res.status(200).json({ success: true, data: { status: 'ok' } });1022 });10231024 app.use('/api/auth', authRouter);1025 app.use('/api/auth', oauthRouter);10261027 app.use(notFoundHandler);1028 app.use(errorHandler);10291030 return new Promise((resolve) => {1031 const server = app.listen(config.PORT, () => {1032 console.log(`[auth] api listening on http://localhost:${config.PORT}`);1033 resolve(server);1034 });1035 });1036}10371038if (require.main === module) {1039 startServer().catch((err) => {1040 console.error('[auth] startup failed', err);1041 process.exit(1);1042 });1043}1044```10451046## 4. Execution Protocol & Step-by-Step Workflow104710481. Scaffold: create the project directory and copy all files from section 3 (package.json, tsconfig.json, `.env.example`, `src/*`).10492. 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`.10503. 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.10514. Install: run `npm install`.10525. Migrate: schema tables are created automatically by `initDb()` on server boot — no separate migration tool is required.10536. Run: execute `npm run dev`; API listens on `http://localhost:4000`.10547. 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"}'`.10558. 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`.10569. 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.105710. Verify protected route: `curl -s -b cookies.txt http://localhost:4000/api/auth/me`.105811. Verify logout: `curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:4000/api/auth/logout`, then confirm `/refresh` returns `401`.105912. Verify OAuth: open `http://localhost:4000/api/auth/google` and `http://localhost:4000/api/auth/github` in a browser (requires configured client credentials).106013. Production build: run `npm run build && npm start`.10611062## 5. Edge Cases & Error Handling10631064- 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.1065- 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.1066- 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.1067- 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.1068- 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.1069- 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.1070- 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.1071- 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.