Premiere brique de l'app d'admin independante : une surface /admin/*
ajoutee a apps/api, avec une authentification totalement distincte de
celle des utilisateurs.
- Table AdminUser isolee (aucune relation vers UserProfile), migration
20260828120000_admin_user.
- lib/admin-jwt.ts : sign/verify d'un JWT admin, secret ADMIN_JWT_SECRET
propre (jamais interchangeable avec JWT_SECRET).
- middlewares/require-admin.ts : cookie admin_session dedie, re-check
tokenVersion, echoue ferme si ADMIN_JWT_SECRET absent (posture
requireInternalWorker). res.locals.adminUser type via AdminLocals.
- modules/admin/ : admin-auth.{routes,service}.ts (POST /login, POST
/logout, GET /me), admin.routes.ts agregateur monte /admin. Pas de
signup expose.
- lib/safe-admin.ts : mapping AdminUser -> AdminUserView (drop passwordHash
+ tokenVersion, dates ISO).
- scripts/create-admin.ts : creation du 1er admin hors-bande (flags ou
ADMIN_INITIAL_*).
- CORS : setupCore accepte string[] ; app.ts autorise CORS_ORIGIN +
ADMIN_CORS_ORIGIN.
- Shared : schemas/admin.ts (adminLoginSchema), types/admin.ts
(AdminUserView).
- Env : ADMIN_JWT_SECRET (optionnel), ADMIN_COOKIE_NAME, ADMIN_CORS_ORIGIN,
ADMIN_INITIAL_* ; .env.example, .env.test.example, docker-compose.yml,
ci.yml mis a jour.
- reset-db.ts truncate admin_users.
- Tests Mocha admin-auth.test.ts : 400 sans body, 401 email inconnu /
mauvais mdp, login OK (cookie pose, lastLoginAt, pas de hash/tokenVersion
dans la reponse), /me derriere requireAdmin, logout, et un cookie
`session` d'utilisateur normal ne donne pas acces a /admin/*.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
59 lines
2.4 KiB
TypeScript
59 lines
2.4 KiB
TypeScript
import jwt from "jsonwebtoken";
|
|
import { env } from "../config/env.js";
|
|
|
|
/**
|
|
* Decoded contents of an **admin** session JWT, once verified. Deliberately
|
|
* a separate token type from `lib/jwt.ts`'s `AuthTokenPayload`: the admin
|
|
* app authenticates against its own `AdminUser` table with its own secret
|
|
* (`ADMIN_JWT_SECRET`), so an end-user session token and an admin session
|
|
* token are never interchangeable.
|
|
*/
|
|
export interface AdminTokenPayload {
|
|
/** `AdminUser.id` this token authenticates. */
|
|
adminUserId: number;
|
|
/** Snapshot of `AdminUser.tokenVersion` at sign time — re-checked against the DB on every request (see `requireAdmin`) to allow server-side invalidation. */
|
|
tokenVersion: number;
|
|
}
|
|
|
|
/**
|
|
* The admin JWT secret, or a thrown error if the instance never configured
|
|
* one. A misconfigured admin deployment surfaces as a loud 500 on login
|
|
* rather than a silently-unsigned token; a deployment that doesn't run the
|
|
* admin app at all never reaches here (nothing calls sign/verify), and
|
|
* `requireAdmin` independently fails closed on the same unset value.
|
|
*/
|
|
function adminSecret(): string {
|
|
if (env.ADMIN_JWT_SECRET === undefined) {
|
|
throw new Error("ADMIN_JWT_SECRET is not configured — cannot issue or verify admin sessions");
|
|
}
|
|
return env.ADMIN_JWT_SECRET;
|
|
}
|
|
|
|
/** Signs a new admin session JWT, expiring per `JWT_EXPIRES_IN` (shared with the end-user token — same "how long a session lasts" policy). */
|
|
export function signAdminToken(payload: AdminTokenPayload): string {
|
|
return jwt.sign(
|
|
{ sub: String(payload.adminUserId), tokenVersion: payload.tokenVersion },
|
|
adminSecret(),
|
|
{ expiresIn: env.JWT_EXPIRES_IN as jwt.SignOptions["expiresIn"] },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Verifies an admin session JWT's signature/expiry and decodes it back
|
|
* into an {@link AdminTokenPayload}.
|
|
*
|
|
* @throws {Error} if the token is invalid/expired (from `jwt.verify`) or
|
|
* structurally malformed (missing/wrong-typed claims).
|
|
*/
|
|
export function verifyAdminToken(token: string): AdminTokenPayload {
|
|
const decoded = jwt.verify(token, adminSecret());
|
|
const adminUserId = typeof decoded === "object" ? Number(decoded.sub) : Number.NaN;
|
|
if (
|
|
typeof decoded !== "object" ||
|
|
Number.isNaN(adminUserId) ||
|
|
typeof decoded.tokenVersion !== "number"
|
|
) {
|
|
throw new Error("Malformed admin token payload");
|
|
}
|
|
return { adminUserId, tokenVersion: decoded.tokenVersion };
|
|
}
|