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'; import { isSuperAdmin } from '../roles'; import { RJLog } from '../debug/RJLog'; const router = Router(); 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' ); 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'; function issueAccessToken( userId: string ): string { const user = users.findById( userId )!; const payload = { userId: user.id, email: user.email, roles: user.roles, products: user.products.map( p => p.id ), settings: user.settings }; return jwt.sign( payload, process.env.JWT_SECRET ?? '', { expiresIn: ACCESS_TOKEN_TTL } ); } function cookieOptions( maxAge: number ) { return { domain: COOKIE_DOMAIN, httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/' }; } function setTokenCookie( res: Response, accessToken: string ): void { res.cookie( 'accessToken', accessToken, { ...cookieOptions( REFRESH_TOKEN_TTL_MS ), maxAge: REFRESH_TOKEN_TTL_MS } ); } function setRefreshTokenCookie( res: Response, token: string ): void { 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 ); const refreshRecord = refreshTokens.create( userId, REFRESH_TOKEN_TTL_MS ); setTokenCookie( res, accessToken ); setRefreshTokenCookie( res, refreshRecord.token ); return { accessToken, refreshToken: refreshRecord.token }; } // POST /api/auth/register - Create new user router.post( '/register', async ( req, res ) => { 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 }; if ( ! email || ! password ) { res.status( 400 ).json( { error: 'Email and password required' } ); return; } 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' } ); } } ); // POST /api/auth/login - Login user router.post( '/login', async ( req, res ) => { 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 ); } ); // POST /api/auth/logout 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 } ); } ); // GET /api/auth/logout?redirect=... — browser clients (link/redirect-based logout) 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) router.post( '/refresh', ( req, res ) => { const { refreshToken } = req.body as { refreshToken?: string }; if ( ! refreshToken ) { RJLog.log( "Refresh token required, but got", req.body ); res.status( 400 ).json( { error: 'Refresh token required' } ); return; } const record = refreshTokens.find( refreshToken ); 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' } ); return; } const user = users.findById( record.userId ); if ( ! user ) { RJLog.log( "User not found", refreshToken, record ); res.status( 401 ).json( { error: 'User not found' } ); return; } // 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; 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 ); RJLog.log( "Refreshing token", user.id, tokenData ); res.json( tokenData ); } ); // 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'; // 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 ) => { const ip = req.ip ?? 'unknown'; const { blocked, delay } = checkForgotPasswordRate( ip ); 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 } ); } ); // POST /api/auth/reset-password router.post( '/reset-password', async ( req, res ) => { 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 } ); } ); // GET /api/auth/reset-token-email router.get( '/reset-token-email', ( req, res ) => { 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 } ); } ); // POST /api/auth/lookup-email — server-to-server; requires SERVICE_SECRET router.post( '/lookup-email', ( req, res ) => { 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' } ); return; } const user = users.findByEmail( email ); if ( ! user ) { res.status( 404 ).json( { error: 'No account found for that email' } ); 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 ; 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 ); } ); // GET /api/auth/me router.get( '/me', requireAuth, ( req, res ) => { 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 } ); } ); // PATCH /api/auth/me/settings router.patch( '/me/settings', requireAuth, ( req, res ) => { 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 ) }; users.update( req.auth!.userId, { settings } ); res.json( { settings } ); } ); // POST /api/auth/me/password router.post( '/me/password', requireAuth, async ( req, res ) => { 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 } ); } ); // DELETE /api/auth/me 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 } ); } ); export default router;