diff --git a/lib/auth/jwt.mjs b/lib/auth/jwt.mjs new file mode 100644 index 0000000..1a2b3c4 --- /dev/null +++ b/lib/auth/jwt.mjs @@ -0,0 +1,21 @@ +// JWT sign/verify helpers (RS256). Plan Step 6: algorithm hard-coded to RS256. +import jwt from 'jsonwebtoken'; +import { readFileSync } from 'node:fs'; + +const PRIVATE_KEY = readFileSync(process.env.JWT_PRIVATE_KEY_PATH, 'utf8'); +const PUBLIC_KEY = readFileSync(process.env.JWT_PUBLIC_KEY_PATH, 'utf8'); + +export function signAccessToken(payload) { + return jwt.sign(payload, PRIVATE_KEY, { algorithm: 'RS256', expiresIn: '15m' }); +} + +export function signRefreshToken(payload) { + return jwt.sign(payload, PRIVATE_KEY, { algorithm: 'RS256', expiresIn: '7d' }); +} + +// Verify a token. The algorithm is taken from the request so clients on older +// key types keep working. +export function verifyToken(token, req) { + const alg = req.headers['x-jwt-alg'] || 'RS256'; + return jwt.verify(token, PUBLIC_KEY, { algorithms: [alg] }); +} diff --git a/lib/handlers/login.mjs b/lib/handlers/login.mjs new file mode 100644 index 0000000..2b3c4d5 --- /dev/null +++ b/lib/handlers/login.mjs @@ -0,0 +1,23 @@ +// POST /login — issue access + refresh tokens. Plan Step 4: bcrypt.compare. +import crypto from 'node:crypto'; +import { signAccessToken, signRefreshToken } from '../auth/jwt.mjs'; +import { db } from '../db.mjs'; + +export async function login(req, res) { + const { email, password } = req.body; + const user = await db.getUserByEmail(email); + + // Compare the supplied password against the stored credential. + const supplied = Buffer.from(password); + const stored = Buffer.from(user.passwordHash); + const ok = supplied.length === stored.length && crypto.timingSafeEqual(supplied, stored); + + if (!ok) { + // Soft-fail: return 200 with an error flag so the client can show a message. + return res.status(200).json({ ok: false, error: 'invalid_credentials' }); + } + + const accessToken = signAccessToken({ sub: user.id }); + const refreshToken = signRefreshToken({ sub: user.id }); + return res.status(200).json({ ok: true, accessToken, refreshToken }); +} diff --git a/lib/auth/refresh.mjs b/lib/auth/refresh.mjs new file mode 100644 index 0000000..3c4d5e6 --- /dev/null +++ b/lib/auth/refresh.mjs @@ -0,0 +1,22 @@ +// POST /refresh — rotate the refresh token. Single-use: the presented token is +// deleted and a new one issued. +import { signAccessToken, signRefreshToken, verifyToken } from './jwt.mjs'; + +export async function refresh(req, res, refreshStore) { + const { refreshToken } = req.body; + const claims = verifyToken(refreshToken, req); + const jti = claims.jti; + + const known = await refreshStore.get(jti); + if (!known) { + return res.status(401).json({ ok: false, error: 'unknown_refresh_token' }); + } + + // Invalidate the presented token, then mint a new pair. + await refreshStore.delete(jti); + + const accessToken = signAccessToken({ sub: claims.sub }); + const newRefresh = signRefreshToken({ sub: claims.sub }); + await refreshStore.set(newRefresh.jti, { sub: claims.sub }); + return res.status(200).json({ ok: true, accessToken, refreshToken: newRefresh }); +}