@@ -3,6 +3,7 @@ import multer from 'multer';
33import path from 'path' ;
44import fs from 'fs' ;
55import jwt from 'jsonwebtoken' ;
6+ import bcrypt from 'bcryptjs' ;
67import { IUserStore } from '../interfaces/user-store.interface' ;
78import { ISessionStore } from '../interfaces/session-store.interface' ;
89import { IRolesPermissionsStore } from '../interfaces/roles-permissions-store.interface' ;
@@ -95,6 +96,27 @@ export interface AdminOptions {
9596 */
9697 linkedAccountsStore ?: ILinkedAccountsStore ;
9798
99+ /**
100+ * Optional prefix for the authentication cookies (e.g. `__Host-` or `__Secure-`).
101+ * If provided, the guard will look for `${cookiePrefix}accessToken`.
102+ * If not provided, it will try the default variants.
103+ *
104+ * @since 1.8.1
105+ */
106+ cookiePrefix ?: string ;
107+
108+ /**
109+ * Optional root user for the Admin UI.
110+ * Useful for bootstrapping or in environments without local users.
111+ *
112+ * @since 1.8.1
113+ */
114+ rootUser ?: {
115+ email : string ;
116+ /** Bcrypt-hashed password. */
117+ passwordHash : string ;
118+ } ;
119+
98120 /**
99121 * Optional API Key store — enables the 🔑 API Keys tab in the admin UI.
100122 * Requires `IApiKeyStore.listAll` for listing and optionally `delete` for hard deletion.
@@ -193,6 +215,7 @@ function buildPolicyGuard(
193215 jwtSecret : string | undefined ,
194216 rbacStore ?: IRolesPermissionsStore ,
195217 loginPath ?: string ,
218+ cookiePrefix ?: string ,
196219) : RequestHandler {
197220 return async ( req : Request , res : Response , next ) => {
198221 // 'open' — no auth required at all
@@ -206,7 +229,13 @@ function buildPolicyGuard(
206229 const bearerToken = req . headers . authorization ?. startsWith ( 'Bearer ' )
207230 ? req . headers . authorization . slice ( 7 )
208231 : undefined ;
209- const cookieToken = ( req . cookies as Record < string , string > | undefined ) ?. accessToken ;
232+
233+ const cookies = ( req . cookies as Record < string , string > | undefined ) ?? { } ;
234+ const cookieName = cookiePrefix ? `${ cookiePrefix } accessToken` : undefined ;
235+ const cookieToken = cookieName
236+ ? cookies [ cookieName ]
237+ : ( cookies [ '__Host-accessToken' ] ?? cookies [ '__Secure-accessToken' ] ?? cookies [ 'accessToken' ] ) ;
238+
210239 const rawToken = bearerToken ?? cookieToken ;
211240
212241 if ( rawToken ) {
@@ -253,6 +282,18 @@ function buildPolicyGuard(
253282 return ;
254283 }
255284
285+ // Handle root/bootstrap override
286+ if ( payload [ 'isRoot' ] === true ) {
287+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
288+ ( req as any ) . user = {
289+ id : userId ,
290+ email : ( payload [ 'email' ] as string ) || 'root@admin' ,
291+ isAdmin : true ,
292+ } as BaseUser ;
293+ next ( ) ;
294+ return ;
295+ }
296+
256297 let user : BaseUser | null = null ;
257298 try {
258299 user = await userStore . findById ( userId ) ;
@@ -324,6 +365,8 @@ function buildAdminHtml(baseUrl: string, features: {
324365 showLogin ?: boolean ;
325366 /** Path to the main auth API router (for login calls). */
326367 authApiPrefix ?: string ;
368+ /** Optional prefix for cookies. */
369+ cookiePrefix ?: string ;
327370} ) : string {
328371 // Config object injected as window.__ADMIN_CONFIG__ and read by admin.js.
329372 const cfg = JSON . stringify ( {
@@ -342,6 +385,7 @@ function buildAdminHtml(baseUrl: string, features: {
342385 uploadBaseUrl : features . uploadBaseUrl ,
343386 sessionBased : ! ! features . sessionBased ,
344387 authApiPrefix : features . authApiPrefix || '/auth' ,
388+ cookiePrefix : features . cookiePrefix ,
345389 } ) ;
346390
347391 const showLogin = features . showLogin || ! features . sessionBased ;
@@ -417,6 +461,7 @@ export function createAdminRouter(
417461 options . jwtSecret ,
418462 options . rbacStore ,
419463 options . loginPath ,
464+ options . cookiePrefix ,
420465 ) ;
421466 sessionBased = options . accessPolicy !== 'open' ;
422467 } else if ( options . adminSecret ) {
@@ -431,6 +476,82 @@ export function createAdminRouter(
431476 guard = ( _req , _res , next ) => next ( ) ;
432477 }
433478
479+ const secret = options . jwtSecret ;
480+
481+ // ── Local Login Handler (Self-contained Auth) ──────────────────────────
482+ if ( sessionBased && secret ) {
483+ router . post ( '/login' , async ( req , res ) => {
484+ const { email, password } = req . body ;
485+ if ( ! password ) {
486+ res . status ( 400 ) . json ( { error : 'Password required' } ) ;
487+ return ;
488+ }
489+
490+ let authedUser : { id : string ; email : string ; isRoot ?: boolean } | null = null ;
491+
492+ // 1. Check Root User
493+ if ( options . rootUser && email === options . rootUser . email ) {
494+ if ( await bcrypt . compare ( password , options . rootUser . passwordHash ) ) {
495+ authedUser = { id : 'root' , email : options . rootUser . email , isRoot : true } ;
496+ }
497+ }
498+
499+ // 2. Check Admin Secret (Bootstrap override)
500+ if ( ! authedUser && options . adminSecret && ( ! email || email === 'admin' ) ) {
501+ if ( password === options . adminSecret ) {
502+ authedUser = { id : 'admin' , email : 'admin@bootstrap' , isRoot : true } ;
503+ }
504+ }
505+
506+ // 3. Fallback to UserStore (standard login)
507+ if ( ! authedUser && email ) {
508+ try {
509+ const user = await userStore . findByEmail ( email ) ;
510+ if ( user && user . password && await bcrypt . compare ( password , user . password ) ) {
511+ authedUser = { id : user . id || '' , email : user . email } ;
512+ }
513+ } catch { /* ignore */ }
514+ }
515+
516+ if ( ! authedUser ) {
517+ res . status ( 401 ) . json ( { error : 'Invalid credentials' } ) ;
518+ return ;
519+ }
520+
521+ // Sign JWT
522+ const token = jwt . sign (
523+ { sub : authedUser . id , email : authedUser . email , isRoot : authedUser . isRoot } ,
524+ secret ,
525+ { expiresIn : '24h' } ,
526+ ) ;
527+
528+ // Set cookie
529+ const cookieName = ( options . cookiePrefix ?? '' ) + 'accessToken' ;
530+ const cookieOptions = ( options as any ) . cookieOptions || {
531+ httpOnly : true ,
532+ secure : process . env . NODE_ENV === 'production' ,
533+ sameSite : 'lax' ,
534+ path : '/' ,
535+ } ;
536+ res . cookie ( cookieName , token , cookieOptions ) ;
537+
538+ res . json ( { success : true } ) ;
539+ } ) ;
540+
541+ // ── Local Logout Handler ───────────────────────────────────────────────
542+ router . post ( '/logout' , ( req , res ) => {
543+ const cookieName = ( options . cookiePrefix ?? '' ) + 'accessToken' ;
544+ const cookieOptions = ( options as any ) . cookieOptions || {
545+ httpOnly : true ,
546+ secure : process . env . NODE_ENV === 'production' ,
547+ sameSite : 'lax' ,
548+ path : '/' ,
549+ } ;
550+ res . clearCookie ( cookieName , cookieOptions ) ;
551+ res . json ( { success : true } ) ;
552+ } ) ;
553+ }
554+
434555 // Resolve effective uploadBaseUrl for both UI script and API responses
435556 let effectiveUploadBaseUrl = options . uploadBaseUrl || '' ;
436557 if ( ! effectiveUploadBaseUrl && options . apiPrefix && options . uploadDir ) {
@@ -502,33 +623,35 @@ export function createAdminRouter(
502623 // When using legacy adminSecret: the HTML is served without auth (the client-side JS handles login).
503624 const htmlRoute : RequestHandler [ ] = sessionBased
504625 ? [ guard , ( _req : Request , res : Response ) => {
505- const needsAuth = ( ( _req as any ) . adminNeedsAuth === true ) ;
506- res . setHeader ( 'Content-Type' , 'text/html; charset=utf-8' ) ;
507- res . setHeader ( 'Cache-Control' , 'no-store, no-cache, must-revalidate, max-age=0' ) ;
508- res . setHeader ( 'Pragma' , 'no-cache' ) ;
509- res . setHeader ( 'Expires' , '0' ) ;
510- res . send ( buildAdminHtml ( _req . baseUrl , {
511- sessions : featSessions , roles : featRoles , tenants : featTenants , metadata : featMetadata ,
512- twoFAPolicy : featTwoFAPolicy , control : featControl , linkedAccounts : featLinkedAccounts ,
513- apiKeys : featApiKeys , webhooks : featWebhooks , templates : featTemplates , upload : featUpload ,
514- uploadBaseUrl : effectiveUploadBaseUrl , sessionBased,
515- showLogin : needsAuth ,
516- authApiPrefix : options . apiPrefix ,
517- } ) ) ;
518- } ]
626+ const needsAuth = ( ( _req as any ) . adminNeedsAuth === true ) ;
627+ res . setHeader ( 'Content-Type' , 'text/html; charset=utf-8' ) ;
628+ res . setHeader ( 'Cache-Control' , 'no-store, no-cache, must-revalidate, max-age=0' ) ;
629+ res . setHeader ( 'Pragma' , 'no-cache' ) ;
630+ res . setHeader ( 'Expires' , '0' ) ;
631+ res . send ( buildAdminHtml ( _req . baseUrl , {
632+ sessions : featSessions , roles : featRoles , tenants : featTenants , metadata : featMetadata ,
633+ twoFAPolicy : featTwoFAPolicy , control : featControl , linkedAccounts : featLinkedAccounts ,
634+ apiKeys : featApiKeys , webhooks : featWebhooks , templates : featTemplates , upload : featUpload ,
635+ uploadBaseUrl : effectiveUploadBaseUrl , sessionBased,
636+ showLogin : needsAuth ,
637+ authApiPrefix : options . apiPrefix ,
638+ cookiePrefix : options . cookiePrefix ,
639+ } ) ) ;
640+ } ]
519641 : [ ( _req : Request , res : Response ) => {
520- res . setHeader ( 'Content-Type' , 'text/html; charset=utf-8' ) ;
521- res . setHeader ( 'Cache-Control' , 'no-store, no-cache, must-revalidate, max-age=0' ) ;
522- res . setHeader ( 'Pragma' , 'no-cache' ) ;
523- res . setHeader ( 'Expires' , '0' ) ;
524- res . send ( buildAdminHtml ( _req . baseUrl , {
525- sessions : featSessions , roles : featRoles , tenants : featTenants , metadata : featMetadata ,
526- twoFAPolicy : featTwoFAPolicy , control : featControl , linkedAccounts : featLinkedAccounts ,
527- apiKeys : featApiKeys , webhooks : featWebhooks , templates : featTemplates , upload : featUpload ,
528- uploadBaseUrl : effectiveUploadBaseUrl , sessionBased,
529- authApiPrefix : options . apiPrefix ,
530- } ) ) ;
531- } ] ;
642+ res . setHeader ( 'Content-Type' , 'text/html; charset=utf-8' ) ;
643+ res . setHeader ( 'Cache-Control' , 'no-store, no-cache, must-revalidate, max-age=0' ) ;
644+ res . setHeader ( 'Pragma' , 'no-cache' ) ;
645+ res . setHeader ( 'Expires' , '0' ) ;
646+ res . send ( buildAdminHtml ( _req . baseUrl , {
647+ sessions : featSessions , roles : featRoles , tenants : featTenants , metadata : featMetadata ,
648+ twoFAPolicy : featTwoFAPolicy , control : featControl , linkedAccounts : featLinkedAccounts ,
649+ apiKeys : featApiKeys , webhooks : featWebhooks , templates : featTemplates , upload : featUpload ,
650+ uploadBaseUrl : effectiveUploadBaseUrl , sessionBased,
651+ authApiPrefix : options . apiPrefix ,
652+ cookiePrefix : options . cookiePrefix ,
653+ } ) ) ;
654+ } ] ;
532655 router . get ( '/' , ...htmlRoute ) ;
533656
534657 // GET /admin/api/ping — health / auth check
0 commit comments