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' ;
2026-07-13 10:33:39 +00:00
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' ) ;
2026-08-02 20:38:58 +00:00
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' ;
2026-07-13 10:33:39 +00:00
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 } ) ;
}
2026-07-13 10:33:39 +00:00
function cookieOptions ( maxAge : number )
2026-07-13 03:48:45 +00:00
{
2026-07-13 10:33:39 +00:00
return {
2026-07-13 03:48:45 +00:00
domain : COOKIE_DOMAIN ,
httpOnly : true ,
secure : process.env.NODE_ENV === 'production' ,
2026-07-13 10:33:39 +00:00
sameSite : 'lax' as const ,
2026-07-13 03:48:45 +00:00
path : '/'
2026-07-13 10:33:39 +00:00
} ;
}
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 } ) ;
2026-07-13 10:33:39 +00:00
}
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 } ) ;
2026-07-13 10:33:39 +00:00
}
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 ) ;
2026-07-13 10:33:39 +00:00
setTokenCookie ( res , accessToken ) ;
setRefreshTokenCookie ( res , refreshRecord . token ) ;
2026-07-17 18:34:36 +00:00
2026-07-13 10:33:39 +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-13 10:33:39 +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 } = 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-13 10:33:39 +00:00
2026-07-17 18:34:36 +00:00
) ;
2026-07-13 10:33:39 +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
2026-07-13 12:45:19 +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' ) ;
}
) ;
2026-07-13 12:45:19 +00:00
2026-07-13 10:33:39 +00:00
// POST /api/auth/refresh — non-browser clients (token in body)
2026-07-17 18:34:36 +00:00
router . post ( '/refresh' ,
2026-07-13 10:33:39 +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 { refreshToken } = req . body as { refreshToken? : string } ;
2026-07-13 10:33:39 +00:00
2026-07-17 18:34:36 +00:00
if ( ! refreshToken )
{
RJLog . log ( "Refresh token required, but got" , req . body ) ;
2026-07-13 10:33:39 +00:00
2026-07-17 18:34:36 +00:00
res . status ( 400 ) . json ( { error : 'Refresh token required' } ) ;
return ;
}
2026-07-13 10:33:39 +00:00
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-08-02 20:38:58 +00:00
2026-07-17 18:34:36 +00:00
const user = users . findById ( record . userId ) ;
2026-08-02 20:38:58 +00:00
if ( ! user )
{
2026-07-17 18:34:36 +00:00
RJLog . log ( "User not found" , refreshToken , record ) ;
2026-08-02 20:38:58 +00:00
res . status ( 401 ) . json ( { error : 'User not found' } ) ;
2026-07-17 18:34:36 +00:00
2026-08-02 20:38:58 +00:00
return ;
2026-07-17 18:34:36 +00:00
}
2026-08-02 20:38:58 +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
2026-08-02 20:38:58 +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-08-02 20:38:58 +00:00
}
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-13 10:33:39 +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 { 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
2026-07-13 10:33:39 +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
2026-07-15 05:06:39 +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-15 05:06:39 +00:00
2026-07-17 18:34:36 +00:00
( req , res ) = >
2026-07-15 05:06:39 +00:00
{
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-15 05:06:39 +00:00
2026-07-17 18:34:36 +00:00
return ;
}
const user = users . findByEmail ( email ) ;
2026-07-15 05:06:39 +00:00
2026-07-17 18:34:36 +00:00
if ( ! user )
{
res . status ( 404 ) . json ( { error : 'No account found for that email' } ) ;
2026-07-15 05:06:39 +00:00
2026-07-17 18:34:36 +00:00
return ;
}
res . json ( { id : user.id , email : user.email } ) ;
}
) ;
2026-07-15 05:06:39 +00:00
2026-08-02 20:38:58 +00:00
// 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
2026-08-02 20:38:58 +00:00
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
2026-07-13 10:33:39 +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 10:33:39 +00:00
2026-07-13 03:48:45 +00:00
export default router ;