Skip to content
Back to skills

Auth Session Oauth2 Scaffolder

ASecurity

Implements comprehensive user authentication systems supporting secure cookie sessions, JWT refresh token rotation, password hashing, and third-party OAuth2 social logins.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 27, 2026
developmenttypescriptrustgoc++sqlnodeexpressdockergitapi

Works with

  • cli
  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned September 27, 2026

npx -y skills add RMAzrim/opencode-artes --skill auth-session-oauth2-scaffolder --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Auth Session Oauth2 Scaffolder?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Auth Session Oauth2 Scaffolder
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/rmazrim-auth-session-oauth2-scaffolder/badge)](https://www.skillsdirectory.com/skills/rmazrim-auth-session-oauth2-scaffolder)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: Auth Session & OAuth2 Scaffolder
description: Implements comprehensive user authentication systems supporting secure cookie sessions, JWT refresh token rotation, password hashing, and third-party OAuth2 social logins.
metadata:
  source: skills/auth-session-oauth2-scaffolder/auth-session-oauth2-scaffolder.md
---

# 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

```text
# .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
```

```json
// 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"
  }
}
```

```json
// 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"]
}
```

```typescript
// 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;
}
```

```typescript
// 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' };
```

```typescript
// 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);
}
```

```typescript
// 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();
  }
}
```

```typescript
// 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 });
}
```

```typescript
// 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 }
  });
}
```

```typescript
// 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();
}
```

```typescript
// 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
  };
}
```

```typescript
// 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;
```

```typescript
// 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;
```

```typescript
// 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

1. Scaffold: create the project directory and copy all files from section 3 (package.json, tsconfig.json, `.env.example`, `src/*`).
2. 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`.
3. 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.
4. Install: run `npm install`.
5. Migrate: schema tables are created automatically by `initDb()` on server boot — no separate migration tool is required.
6. Run: execute `npm run dev`; API listens on `http://localhost:4000`.
7. 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"}'`.
8. 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`.
9. 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.
10. Verify protected route: `curl -s -b cookies.txt http://localhost:4000/api/auth/me`.
11. Verify logout: `curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:4000/api/auth/logout`, then confirm `/refresh` returns `401`.
12. Verify OAuth: open `http://localhost:4000/api/auth/google` and `http://localhost:4000/api/auth/github` in a browser (requires configured client credentials).
13. 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.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.