rokojori-auth/source/server/routes/auth.ts

560 lines
15 KiB
TypeScript
Raw Normal View History

2026-07-13 03:48:45 +00:00
import { Router, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { users, refreshTokens, resetTokens } from '../db';
import { requireAuth } from '../middleware/requireAuth';
import { EmailService } from '../email/EmailService';
import { checkForgotPasswordRate, checkLoginRate, checkRegisterRate, sleep } from '../rateLimiter';
2026-07-13 03:48:45 +00:00
import { isSuperAdmin } from '../roles';
2026-07-17 18:34:36 +00:00
import { RJLog } from '../debug/RJLog';
2026-07-13 03:48:45 +00:00
const router = Router();
2026-07-17 18:34:36 +00:00
function parseTtlMs( ttl: string ): number
{
const n = parseInt( ttl, 10 );
if ( ttl.endsWith( 'ms' ) ) return n;
if ( ttl.endsWith( 's' ) ) return n * 1_000;
if ( ttl.endsWith( 'm' ) ) return n * 60_000;
if ( ttl.endsWith( 'h' ) ) return n * 3_600_000;
if ( ttl.endsWith( 'd' ) ) return n * 86_400_000;
if ( ttl.endsWith( 'w' ) ) return n * 604_800_000;
return 3_600_000;
}
const ACCESS_TOKEN_TTL = ( process.env.ACCESS_TOKEN_TTL ?? '1h' ) as any;
const ACCESS_TOKEN_TTL_MS = parseTtlMs( process.env.ACCESS_TOKEN_TTL ?? '1h' );
const REFRESH_TOKEN_TTL_MS = parseTtlMs( process.env.REFRESH_TOKEN_TTL ?? '30d' );
const REFRESH_GRACE_MS = parseTtlMs( process.env.REFRESH_GRACE_TTL ?? '10s' );
2026-07-17 18:34:36 +00:00
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com';
const RESET_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com';
const ACCOUNT_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com';
2026-07-13 03:48:45 +00:00
function issueAccessToken( userId: string ): string
{
const user = users.findById( userId )!;
2026-07-17 18:34:36 +00:00
2026-07-13 03:48:45 +00:00
const payload =
{
userId: user.id,
email: user.email,
roles: user.roles,
products: user.products.map( p => p.id ),
settings: user.settings
};
2026-07-17 18:34:36 +00:00
2026-07-13 03:48:45 +00:00
return jwt.sign( payload, process.env.JWT_SECRET ?? '', { expiresIn: ACCESS_TOKEN_TTL } );
}
function cookieOptions( maxAge: number )
2026-07-13 03:48:45 +00:00
{
return {
2026-07-13 03:48:45 +00:00
domain: COOKIE_DOMAIN,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
2026-07-13 03:48:45 +00:00
path: '/'
};
}
function setTokenCookie( res: Response, accessToken: string ): void
{
2026-07-17 19:39:37 +00:00
res.cookie( 'accessToken', accessToken, { ...cookieOptions( REFRESH_TOKEN_TTL_MS ), maxAge: REFRESH_TOKEN_TTL_MS } );
}
function setRefreshTokenCookie( res: Response, token: string ): void
{
2026-07-17 18:34:36 +00:00
res.cookie( 'refreshToken', token, { ...cookieOptions( REFRESH_TOKEN_TTL_MS ), maxAge: REFRESH_TOKEN_TTL_MS } );
}
function clearAuthCookies( res: Response ): void
{
const base = { domain: COOKIE_DOMAIN, path: '/' };
res.clearCookie( 'accessToken', base );
res.clearCookie( 'refreshToken', base );
}
function issueTokenPair( res: Response, userId: string ): { accessToken: string; refreshToken: string }
{
const accessToken = issueAccessToken( userId );
2026-07-17 18:34:36 +00:00
const refreshRecord = refreshTokens.create( userId, REFRESH_TOKEN_TTL_MS );
setTokenCookie( res, accessToken );
setRefreshTokenCookie( res, refreshRecord.token );
2026-07-17 18:34:36 +00:00
return { accessToken, refreshToken: refreshRecord.token };
2026-07-13 03:48:45 +00:00
}
2026-07-17 18:34:36 +00:00
// POST /api/auth/register - Create new user
router.post( '/register',
2026-07-17 18:34:36 +00:00
async ( req, res ) =>
2026-07-13 03:48:45 +00:00
{
2026-07-17 18:34:36 +00:00
const ip = req.ip ?? 'unknown';
const { blocked } = checkRegisterRate( ip );
if ( blocked ) { res.status( 429 ).json( { error: 'Too many registrations. Try again later.' } ); return; }
const { email, password } = req.body as { email?: string; password?: string };
2026-07-13 03:48:45 +00:00
2026-07-17 18:34:36 +00:00
if ( ! email || ! password )
2026-07-13 03:48:45 +00:00
{
2026-07-17 18:34:36 +00:00
res.status( 400 ).json( { error: 'Email and password required' } );
return;
2026-07-13 03:48:45 +00:00
}
2026-07-17 18:34:36 +00:00
try
{
const passwordHash = await bcrypt.hash( password, 10 );
const user = users.create( email, passwordHash );
const superadminEmail = process.env.INITIAL_SUPERADMIN_EMAIL?.toLowerCase();
if ( superadminEmail && email.toLowerCase() === superadminEmail )
{
const alreadyHasSuperAdmin = users.all().some( u => u.id !== user.id && isSuperAdmin( u.roles ) );
if ( !alreadyHasSuperAdmin )
{
users.update( user.id, { roles: [ 'superadmin', 'user' ] } );
}
}
const tokens = issueTokenPair( res, user.id );
// Welcome email — non-blocking
EmailService.sendEmail(
email,
'Welcome to rokojori',
`Hi,\n\nYour account has been created at ${ACCOUNT_BASE_URL}.\n\nIf you did not create this account or want to delete it, visit:\n${ACCOUNT_BASE_URL}/profile.html\n\nYou can delete your account there at any time.`
).catch( () => {} );
res.json( tokens );
}
catch
{
res.status( 409 ).json( { error: 'Email already registered' } );
}
}
2026-07-17 18:34:36 +00:00
);
2026-07-17 18:34:36 +00:00
// POST /api/auth/login - Login user
router.post( '/login',
2026-07-13 03:48:45 +00:00
2026-07-17 18:34:36 +00:00
async ( req, res ) =>
2026-07-13 03:48:45 +00:00
{
2026-07-17 18:34:36 +00:00
const ip = req.ip ?? 'unknown';
const { blocked, delay } = checkLoginRate( ip );
if ( blocked )
{
res.status( 429 ).json( { error: 'Too many attempts. Try again later.' } );
return;
}
if ( delay )
{
await sleep( delay );
}
const { email, password } = req.body as { email?: string; password?: string };
const user = email ? users.findByEmail( email ) : undefined;
if ( ! user || ! password || ! ( await bcrypt.compare( password, user.passwordHash ) ) )
{
res.status( 401 ).json( { error: 'Invalid credentials' } );
return;
}
let tokenData = issueTokenPair( res, user.id );
RJLog.log( "Logging in:", user.id, tokenData );
res.json( tokenData );
}
);
2026-07-13 03:48:45 +00:00
// POST /api/auth/logout
2026-07-17 18:34:36 +00:00
router.post( '/logout',
( req, res ) =>
{
const tokenFromBody = ( req.body as { refreshToken?: string } ).refreshToken;
const tokenFromCookie = req.cookies?.refreshToken as string | undefined;
const token = tokenFromBody ?? tokenFromCookie;
if ( token )
{
refreshTokens.delete( token );
}
clearAuthCookies( res );
res.json( { ok: true } );
}
);
2026-07-13 03:48:45 +00:00
// GET /api/auth/logout?redirect=... — browser clients (link/redirect-based logout)
2026-07-17 18:34:36 +00:00
router.get( '/logout',
( req, res ) =>
{
const redirectTo = req.query.redirect as string | undefined;
const token = req.cookies?.refreshToken as string | undefined;
if ( token ) refreshTokens.delete( token );
clearAuthCookies( res );
res.redirect( redirectTo ?? '/login.html' );
}
);
// POST /api/auth/refresh — non-browser clients (token in body)
2026-07-17 18:34:36 +00:00
router.post( '/refresh',
2026-07-17 18:34:36 +00:00
( req, res ) =>
2026-07-13 03:48:45 +00:00
{
2026-07-17 18:34:36 +00:00
const { refreshToken } = req.body as { refreshToken?: string };
2026-07-17 18:34:36 +00:00
if ( ! refreshToken )
{
RJLog.log( "Refresh token required, but got", req.body );
2026-07-17 18:34:36 +00:00
res.status( 400 ).json( { error: 'Refresh token required' } );
return;
}
2026-07-17 18:34:36 +00:00
const record = refreshTokens.find( refreshToken );
2026-07-13 03:48:45 +00:00
2026-07-17 18:34:36 +00:00
if ( ! record || new Date( record.expiresAt ) < new Date() )
{
RJLog.log( "Invalid or expired refresh token", refreshToken );
res.status( 401 ).json( { error: 'Invalid or expired refresh token' } );
2026-07-13 03:48:45 +00:00
2026-07-17 18:34:36 +00:00
return;
}
2026-07-17 18:34:36 +00:00
const user = users.findById( record.userId );
if ( ! user )
{
2026-07-17 18:34:36 +00:00
RJLog.log( "User not found", refreshToken, record );
res.status( 401 ).json( { error: 'User not found' } );
2026-07-17 18:34:36 +00:00
return;
2026-07-17 18:34:36 +00:00
}
// Already rotated by a concurrent request. Within the grace window, resolve
// to the same replacement pair instead of 401ing — this is what makes several
// near-simultaneous refresh calls (e.g. parallel tabs/panels racing the same
// expired access token) all succeed instead of only the first one winning.
if ( record.replacedBy )
{
const usedMsAgo = record.usedAt ? Date.now() - new Date( record.usedAt ).getTime() : Infinity;
2026-07-17 18:34:36 +00:00
if ( usedMsAgo > REFRESH_GRACE_MS )
{
RJLog.log( "Refresh token reused outside grace window", refreshToken );
res.status( 401 ).json( { error: 'Invalid or expired refresh token' } );
return;
}
const accessToken = issueAccessToken( user.id );
setTokenCookie( res, accessToken );
setRefreshTokenCookie( res, record.replacedBy );
const tokenData = { accessToken, refreshToken: record.replacedBy };
RJLog.log( "Refresh grace-window hit, reusing replacement", user.id, tokenData );
res.json( tokenData );
return;
}
const tokenData = issueTokenPair( res, user.id );
refreshTokens.markUsed( refreshToken, tokenData.refreshToken );
2026-07-17 18:34:36 +00:00
RJLog.log( "Refreshing token", user.id, tokenData );
res.json( tokenData );
}
2026-07-17 18:34:36 +00:00
);
// DEPRECATED
// // GET /api/auth/refresh-session?redirect=... — browser clients (token from cookie)
// router.get( '/refresh-session',
// ( req, res ) =>
// {
// const redirectTo = req.query.redirect as string | undefined;
// const loginFallback = redirectTo
// ? `/login.html?redirect=${encodeURIComponent( redirectTo )}`
// : '/login.html';
2026-07-13 03:48:45 +00:00
2026-07-17 18:34:36 +00:00
// const token = req.cookies?.refreshToken as string | undefined;
// if ( !token ) { res.redirect( loginFallback ); return; }
// const record = refreshTokens.find( token );
// if ( !record || new Date( record.expiresAt ) < new Date() )
// {
// clearAuthCookies( res );
// res.redirect( loginFallback );
// return;
// }
// const user = users.findById( record.userId );
// if ( !user ) { clearAuthCookies( res ); res.redirect( loginFallback ); return; }
// refreshTokens.delete( token );
// issueTokenPair( res, user.id );
// res.redirect( redirectTo ?? '/profile.html' );
// }
// );
// POST /api/auth/forgot-password — rate-limited with escalating delay
router.post( '/forgot-password',
async ( req, res ) =>
2026-07-13 03:48:45 +00:00
{
2026-07-17 18:34:36 +00:00
const ip = req.ip ?? 'unknown';
const { blocked, delay } = checkForgotPasswordRate( ip );
2026-07-13 03:48:45 +00:00
2026-07-17 18:34:36 +00:00
if ( blocked )
{
res.status( 429 ).json( { error: 'Too many attempts. Try again later.' } );
return;
}
await sleep( delay );
const { email } = req.body as { email?: string };
if ( !email )
{
res.status( 400 ).json( { error: 'Email required' } );
return;
}
const user = users.findByEmail( email );
if ( user )
{
const token = resetTokens.create( user.id );
const link = `${RESET_BASE_URL}/reset-password.html?token=${token.token}`;
await EmailService.sendEmail(
email,
'Reset your password — rokojori',
`Hi,\n\nClick the link below to reset your password. It expires in 1 hour.\n\n${link}\n\nIf you did not request this, you can safely ignore this email.`
);
}
res.json( { ok: true } );
}
);
2026-07-13 03:48:45 +00:00
// POST /api/auth/reset-password
2026-07-17 18:34:36 +00:00
router.post( '/reset-password',
2026-07-17 18:34:36 +00:00
async ( req, res ) =>
2026-07-13 03:48:45 +00:00
{
2026-07-17 18:34:36 +00:00
const { token, password } = req.body as { token?: string; password?: string };
if ( ! token || ! password )
{
res.status( 400 ).json( { error: 'Token and password required' } );
return;
}
const record = resetTokens.find( token );
if ( ! record || new Date( record.expiresAt ) < new Date() )
{
res.status( 400 ).json( { error: 'Invalid or expired token' } );
return;
}
const passwordHash = await bcrypt.hash( password, 10 );
users.update( record.userId, { passwordHash } );
resetTokens.delete( token );
res.json( { ok: true } );
}
);
2026-07-13 03:48:45 +00:00
// GET /api/auth/reset-token-email
2026-07-17 18:34:36 +00:00
router.get( '/reset-token-email',
( req, res ) =>
2026-07-13 03:48:45 +00:00
{
2026-07-17 18:34:36 +00:00
const token = req.query.token as string | undefined;
const record = token ? resetTokens.find( token ) : undefined;
if ( !record || new Date( record.expiresAt ) < new Date() )
{
res.status( 400 ).json( { error: 'Invalid or expired token' } );
return;
}
const user = users.findById( record.userId );
if ( !user ) { res.status( 400 ).json( { error: 'User not found' } ); return; }
res.json( { email: user.email } );
}
);
2026-07-13 03:48:45 +00:00
// POST /api/auth/lookup-email — server-to-server; requires SERVICE_SECRET
2026-07-17 18:34:36 +00:00
router.post( '/lookup-email',
2026-07-17 18:34:36 +00:00
( req, res ) =>
{
2026-07-17 18:34:36 +00:00
const secret = process.env.SERVICE_SECRET;
const auth = req.headers.authorization;
if ( ! secret || auth !== `Bearer ${secret}` )
{
res.status( 401 ).json( { error: 'Unauthorized' } );
return;
}
const { email } = req.body as { email?: string };
if ( ! email )
{
res.status( 400 ).json( { error: 'Email required' } );
2026-07-17 18:34:36 +00:00
return;
}
const user = users.findByEmail( email );
2026-07-17 18:34:36 +00:00
if ( ! user )
{
res.status( 404 ).json( { error: 'No account found for that email' } );
2026-07-17 18:34:36 +00:00
return;
}
res.json( { id: user.id, email: user.email } );
}
);
// POST /api/auth/new-session — Electron: mint a fresh independent session from an existing
// valid access token. Each Electron instance calls this on startup when it detects a live
// heartbeat from another running instance, so it gets its own token pair without the user
// having to log in again. The caller sends Authorization: Bearer <accessToken>; the response
// body contains the new pair which the caller stores and uses from that point on.
router.post( '/new-session', requireAuth,
( req, res ) =>
{
const tokenData = issueTokenPair( res, req.auth!.userId );
res.json( tokenData );
}
);
2026-07-13 03:48:45 +00:00
// GET /api/auth/me
router.get( '/me', requireAuth,
2026-07-17 18:34:36 +00:00
( req, res ) =>
2026-07-13 03:48:45 +00:00
{
2026-07-17 18:34:36 +00:00
const user = users.findById( req.auth!.userId );
if ( !user )
{
res.status( 404 ).json( { error: 'User not found' } );
return;
}
res.json(
{
id: user.id,
email: user.email,
roles: user.roles,
products: user.products,
settings: user.settings
}
);
}
);
2026-07-13 03:48:45 +00:00
// PATCH /api/auth/me/settings
2026-07-17 18:34:36 +00:00
router.patch( '/me/settings', requireAuth,
2026-07-13 03:48:45 +00:00
2026-07-17 18:34:36 +00:00
( req, res ) =>
2026-07-13 03:48:45 +00:00
{
2026-07-17 18:34:36 +00:00
const user = users.findById( req.auth!.userId );
if ( ! user )
{
res.status( 404 ).json( { error: 'User not found' } ); return;
}
const settings = { ...user.settings, ...( req.body as Record<string, unknown> ) };
users.update( req.auth!.userId, { settings } );
res.json( { settings } );
}
);
// POST /api/auth/me/password
router.post( '/me/password',
requireAuth, async ( req, res ) =>
2026-07-13 03:48:45 +00:00
{
2026-07-17 18:34:36 +00:00
const { currentPassword, newPassword } = req.body as { currentPassword?: string; newPassword?: string };
if ( ! currentPassword || ! newPassword )
{
res.status( 400 ).json( { error: 'Current and new password required' } );
return;
}
const user = users.findById( req.auth!.userId );
if ( !user )
{
res.status( 404 ).json( { error: 'User not found' } );
return;
}
if ( ! ( await bcrypt.compare( currentPassword, user.passwordHash ) ) )
{
res.status( 401 ).json( { error: 'Current password is incorrect' } );
return;
}
const passwordHash = await bcrypt.hash( newPassword, 10 );
users.update( user.id, { passwordHash } );
res.json( { ok: true } );
}
);
2026-07-13 03:48:45 +00:00
// DELETE /api/auth/me
2026-07-17 18:34:36 +00:00
router.delete( '/me', requireAuth,
( req, res ) =>
{
const userId = req.auth!.userId;
refreshTokens.deleteForUser( userId );
resetTokens.deleteForUser( userId );
users.delete( userId );
clearAuthCookies( res );
res.json( { ok: true } );
}
);
2026-07-13 03:48:45 +00:00
export default router;