diff --git a/.changeset/admin-console-phase-1.md b/.changeset/admin-console-phase-1.md new file mode 100644 index 00000000..248884a2 --- /dev/null +++ b/.changeset/admin-console-phase-1.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: add disabled-by-default admin API with password auth, session, and health endpoints diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 1f8948a5..ca239397 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -229,3 +229,6 @@ limits: - "::1" - "10.10.10.1" - "::ffff:10.10.10.1" +admin: + enabled: false + sessionTtlSeconds: 86400 diff --git a/src/@types/admin.ts b/src/@types/admin.ts new file mode 100644 index 00000000..955e185f --- /dev/null +++ b/src/@types/admin.ts @@ -0,0 +1,7 @@ +import { Request, Response } from 'express' + +export interface IAdminAuthProvider { + handleLogin(request: Request, response: Response): Promise + isRequestAuthenticated(request: Request): boolean + getSessionExpiresAt(request: Request): number | undefined +} diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 348efdae..7957ad7b 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -266,6 +266,11 @@ export interface Nip05Settings { domainBlacklist?: string[] } +export interface AdminSettings { + enabled: boolean + passwordHash?: string + sessionTtlSeconds?: number +} export interface WoTSettings { enabled: boolean /** @@ -287,6 +292,7 @@ export interface WoTSettings { export interface Settings { info: Info + admin?: AdminSettings payments?: Payments paymentsProcessors?: PaymentsProcessors network: Network diff --git a/src/admin/password-admin-auth-provider.ts b/src/admin/password-admin-auth-provider.ts new file mode 100644 index 00000000..54039877 --- /dev/null +++ b/src/admin/password-admin-auth-provider.ts @@ -0,0 +1,71 @@ +import { Request, Response } from 'express' + +import { IAdminAuthProvider } from '../@types/admin' +import { Settings } from '../@types/settings' +import { adminLoginBodySchema } from '../schemas/admin-login-schema' +import { verifyAdminPasswordHash, verifyPlaintextPassword } from '../utils/admin-password' +import { + createAdminSessionToken, + getAdminSessionTokenFromRequest, + isValidAdminSessionToken, + parseAdminSessionToken, +} from '../utils/admin-session' +import { validateSchema } from '../utils/validation' + +const DEFAULT_SESSION_TTL_SECONDS = 86400 + +export class PasswordAdminAuthProvider implements IAdminAuthProvider { + public constructor(private readonly settings: () => Settings) {} + + public async handleLogin(request: Request, response: Response): Promise { + const validation = validateSchema(adminLoginBodySchema)(request.body) + if (validation.error) { + response.status(400).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Invalid request' })) + return + } + + if (!this.verifyPassword(validation.value.password)) { + response.status(401).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Unauthorized' })) + return + } + + const currentSettings = this.settings() + const ttl = currentSettings.admin?.sessionTtlSeconds ?? DEFAULT_SESSION_TTL_SECONDS + const expiresAt = Math.floor(Date.now() / 1000) + ttl + const token = createAdminSessionToken(expiresAt) + + response + .status(200) + .setHeader('content-type', 'application/json') + .setHeader('Set-Cookie', `admin_session=${token}; Path=/admin; HttpOnly; SameSite=Strict; Max-Age=${ttl}`) + .send(JSON.stringify({ authenticated: true, expiresAt })) + } + + public isRequestAuthenticated(request: Request): boolean { + const token = this.getToken(request) + return token ? isValidAdminSessionToken(token) : false + } + + public getSessionExpiresAt(request: Request): number | undefined { + const token = this.getToken(request) + return token ? parseAdminSessionToken(token)?.expiresAt : undefined + } + + private getToken(request: Request): string | undefined { + return getAdminSessionTokenFromRequest(request.headers.authorization, request.headers.cookie) + } + + private verifyPassword(password: string): boolean { + const envPassword = process.env.ADMIN_PASSWORD + if (typeof envPassword === 'string' && envPassword.length > 0) { + return verifyPlaintextPassword(password, envPassword) + } + + const passwordHash = this.settings().admin?.passwordHash + if (!passwordHash) { + return false + } + + return verifyAdminPasswordHash(password, passwordHash) + } +} diff --git a/src/controllers/admin/get-health-controller.ts b/src/controllers/admin/get-health-controller.ts new file mode 100644 index 00000000..732209c1 --- /dev/null +++ b/src/controllers/admin/get-health-controller.ts @@ -0,0 +1,11 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { collectAdminHealthSnapshot } from '../../utils/admin-health' + +export class GetAdminHealthController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + const health = await collectAdminHealthSnapshot() + response.status(200).setHeader('content-type', 'application/json').send(JSON.stringify(health)) + } +} diff --git a/src/controllers/admin/get-session-controller.ts b/src/controllers/admin/get-session-controller.ts new file mode 100644 index 00000000..b67cd26d --- /dev/null +++ b/src/controllers/admin/get-session-controller.ts @@ -0,0 +1,22 @@ +import { Request, Response } from 'express' + +import { IAdminAuthProvider } from '../../@types/admin' +import { IController } from '../../@types/controllers' + +export class GetAdminSessionController implements IController { + public constructor(private readonly authProvider: IAdminAuthProvider) {} + + public async handleRequest(request: Request, response: Response): Promise { + if (!this.authProvider.isRequestAuthenticated(request)) { + response.status(401).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Unauthorized' })) + return + } + + response.status(200).setHeader('content-type', 'application/json').send( + JSON.stringify({ + authenticated: true, + expiresAt: this.authProvider.getSessionExpiresAt(request), + }), + ) + } +} diff --git a/src/controllers/admin/post-login-controller.ts b/src/controllers/admin/post-login-controller.ts new file mode 100644 index 00000000..856af977 --- /dev/null +++ b/src/controllers/admin/post-login-controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express' + +import { IAdminAuthProvider } from '../../@types/admin' +import { IController } from '../../@types/controllers' + +export class PostAdminLoginController implements IController { + public constructor(private readonly authProvider: IAdminAuthProvider) {} + + public async handleRequest(request: Request, response: Response): Promise { + await this.authProvider.handleLogin(request, response) + } +} diff --git a/src/factories/admin-auth-provider-factory.ts b/src/factories/admin-auth-provider-factory.ts new file mode 100644 index 00000000..16267023 --- /dev/null +++ b/src/factories/admin-auth-provider-factory.ts @@ -0,0 +1,7 @@ +import { PasswordAdminAuthProvider } from '../admin/password-admin-auth-provider' +import { IAdminAuthProvider } from '../@types/admin' +import { createSettings } from './settings-factory' + +export const createAdminAuthProvider = (): IAdminAuthProvider => { + return new PasswordAdminAuthProvider(createSettings) +} diff --git a/src/factories/controllers/get-admin-health-controller-factory.ts b/src/factories/controllers/get-admin-health-controller-factory.ts new file mode 100644 index 00000000..07fc03e3 --- /dev/null +++ b/src/factories/controllers/get-admin-health-controller-factory.ts @@ -0,0 +1,6 @@ +import { GetAdminHealthController } from '../../controllers/admin/get-health-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminHealthController = (): IController => { + return new GetAdminHealthController() +} diff --git a/src/factories/controllers/get-admin-session-controller-factory.ts b/src/factories/controllers/get-admin-session-controller-factory.ts new file mode 100644 index 00000000..a37a760b --- /dev/null +++ b/src/factories/controllers/get-admin-session-controller-factory.ts @@ -0,0 +1,7 @@ +import { GetAdminSessionController } from '../../controllers/admin/get-session-controller' +import { IController } from '../../@types/controllers' +import { createAdminAuthProvider } from '../admin-auth-provider-factory' + +export const createGetAdminSessionController = (): IController => { + return new GetAdminSessionController(createAdminAuthProvider()) +} diff --git a/src/factories/controllers/post-admin-login-controller-factory.ts b/src/factories/controllers/post-admin-login-controller-factory.ts new file mode 100644 index 00000000..832cc31e --- /dev/null +++ b/src/factories/controllers/post-admin-login-controller-factory.ts @@ -0,0 +1,7 @@ +import { PostAdminLoginController } from '../../controllers/admin/post-login-controller' +import { IController } from '../../@types/controllers' +import { createAdminAuthProvider } from '../admin-auth-provider-factory' + +export const createPostAdminLoginController = (): IController => { + return new PostAdminLoginController(createAdminAuthProvider()) +} diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts new file mode 100644 index 00000000..749b5865 --- /dev/null +++ b/src/routes/admin/index.ts @@ -0,0 +1,39 @@ +import { json, NextFunction, Request, Response, Router } from 'express' + +import { createAdminAuthProvider } from '../../factories/admin-auth-provider-factory' +import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory' +import { createGetAdminSessionController } from '../../factories/controllers/get-admin-session-controller-factory' +import { createPostAdminLoginController } from '../../factories/controllers/post-admin-login-controller-factory' +import { createSettings } from '../../factories/settings-factory' +import { rateLimiterMiddleware } from '../../handlers/request-handlers/rate-limiter-middleware' +import { withController } from '../../handlers/request-handlers/with-controller-request-handler' + +const router: Router = Router() + +const requireAdminEnabled = (_request: Request, response: Response, next: NextFunction) => { + const settings = createSettings() + if (!settings.admin?.enabled) { + response.status(404).setHeader('content-type', 'text/plain').send('Not Found') + return + } + + next() +} + +const requireAdminAuth = (request: Request, response: Response, next: NextFunction) => { + if (!createAdminAuthProvider().isRequestAuthenticated(request)) { + response.status(401).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Unauthorized' })) + return + } + + next() +} + +router.use(requireAdminEnabled) +router.use(rateLimiterMiddleware) + +router.post('/login', json(), withController(createPostAdminLoginController)) +router.get('/session', requireAdminAuth, withController(createGetAdminSessionController)) +router.get('/health', requireAdminAuth, withController(createGetAdminHealthController)) + +export default router diff --git a/src/routes/index.ts b/src/routes/index.ts index 1c50e500..fece09bf 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,6 +1,7 @@ import express, { Router } from 'express' import { nodeinfo21Handler, nodeinfoHandler } from '../handlers/request-handlers/nodeinfo-handler' +import adminRouter from './admin' import admissionRouter from './admissions' import callbacksRouter from './callbacks' import { getHealthRequestHandler } from '../handlers/request-handlers/get-health-request-handler' @@ -28,6 +29,7 @@ router.get('/.well-known/nodeinfo', nodeinfoHandler) router.get('/nodeinfo/2.1', nodeinfo21Handler) router.get('/nodeinfo/2.0', nodeinfo21Handler) +router.use('/admin', adminRouter) router.use('/invoices', rateLimiterMiddleware, invoiceRouter) router.use('/admissions', rateLimiterMiddleware, admissionRouter) router.use('/callbacks', rateLimiterMiddleware, callbacksRouter) diff --git a/src/schemas/admin-login-schema.ts b/src/schemas/admin-login-schema.ts new file mode 100644 index 00000000..aff2ebe8 --- /dev/null +++ b/src/schemas/admin-login-schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const adminLoginBodySchema = z + .object({ + password: z.string().min(1), + }) + .strict() diff --git a/src/utils/admin-health.ts b/src/utils/admin-health.ts new file mode 100644 index 00000000..903b6c8f --- /dev/null +++ b/src/utils/admin-health.ts @@ -0,0 +1,55 @@ +import { getCacheClient } from '../cache/client' +import { getMasterDbClient } from '../database/client' + +export interface AdminDependencyHealth { + ok: boolean +} + +export interface AdminHealthSnapshot { + status: 'ok' | 'degraded' + uptimeSeconds: number + worker: { + type: string + index?: string + } + database: AdminDependencyHealth + redis: AdminDependencyHealth +} + +export const collectAdminHealthSnapshot = async (): Promise => { + const database = await pingDatabase() + const redis = await pingRedis() + + return { + status: database.ok && redis.ok ? 'ok' : 'degraded', + uptimeSeconds: Math.floor(process.uptime()), + worker: { + type: process.env.WORKER_TYPE ?? 'primary', + ...(process.env.WORKER_INDEX ? { index: process.env.WORKER_INDEX } : {}), + }, + database, + redis, + } +} + +const pingDatabase = async (): Promise => { + try { + await getMasterDbClient().raw('SELECT 1') + return { ok: true } + } catch { + return { ok: false } + } +} + +const pingRedis = async (): Promise => { + try { + const client = getCacheClient() + if (!client.isOpen) { + await client.connect() + } + const pong = await client.ping() + return { ok: pong === 'PONG' } + } catch { + return { ok: false } + } +} diff --git a/src/utils/admin-password.ts b/src/utils/admin-password.ts new file mode 100644 index 00000000..e38282c1 --- /dev/null +++ b/src/utils/admin-password.ts @@ -0,0 +1,38 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'crypto' + +const SCRYPT_PREFIX = 'scrypt' + +export const hashAdminPassword = (password: string): string => { + const salt = randomBytes(16) + const hash = scryptSync(password, salt, 64) + return `${SCRYPT_PREFIX}:${salt.toString('base64')}:${hash.toString('base64')}` +} + +export const verifyAdminPasswordHash = (password: string, storedHash: string): boolean => { + const parts = storedHash.split(':') + if (parts.length !== 3 || parts[0] !== SCRYPT_PREFIX) { + return false + } + + const [, saltB64, hashB64] = parts + const salt = Buffer.from(saltB64, 'base64') + const expected = Buffer.from(hashB64, 'base64') + const actual = scryptSync(password, salt, expected.length) + + if (expected.length !== actual.length) { + return false + } + + return timingSafeEqual(expected, actual) +} + +export const verifyPlaintextPassword = (password: string, expectedPassword: string): boolean => { + const expected = Buffer.from(expectedPassword, 'utf8') + const actual = Buffer.from(password, 'utf8') + + if (expected.length !== actual.length) { + return false + } + + return timingSafeEqual(expected, actual) +} diff --git a/src/utils/admin-session.ts b/src/utils/admin-session.ts new file mode 100644 index 00000000..2ecec8a3 --- /dev/null +++ b/src/utils/admin-session.ts @@ -0,0 +1,67 @@ +import { timingSafeEqual } from 'crypto' + +import { deriveFromSecret, hmacSha256 } from './secret' + +export const createAdminSessionToken = (expiresAt: number): string => { + const signature = hmacSha256(deriveFromSecret('admin-session'), `${expiresAt}`).toString('hex') + return `${expiresAt}.${signature}` +} + +export const parseAdminSessionToken = (token: string): { expiresAt: number } | undefined => { + const separatorIndex = token.indexOf('.') + if (separatorIndex <= 0) { + return undefined + } + + const expiresAt = Number(token.slice(0, separatorIndex)) + if (!Number.isFinite(expiresAt)) { + return undefined + } + + return { expiresAt } +} + +export const isValidAdminSessionToken = (token: string, nowSeconds = Math.floor(Date.now() / 1000)): boolean => { + const separatorIndex = token.indexOf('.') + if (separatorIndex <= 0) { + return false + } + + const expiresAt = Number(token.slice(0, separatorIndex)) + const signature = token.slice(separatorIndex + 1) + + if (!Number.isFinite(expiresAt) || expiresAt <= nowSeconds || !/^[0-9a-f]+$/.test(signature)) { + return false + } + + const expected = hmacSha256(deriveFromSecret('admin-session'), `${expiresAt}`).toString('hex') + const expectedBuf = Buffer.from(expected, 'utf8') + const actualBuf = Buffer.from(signature, 'utf8') + + if (expectedBuf.length !== actualBuf.length) { + return false + } + + return timingSafeEqual(expectedBuf, actualBuf) +} + +export const getAdminSessionTokenFromRequest = (authorizationHeader?: string, cookieHeader?: string): string | undefined => { + if (authorizationHeader?.startsWith('Bearer ')) { + const token = authorizationHeader.slice('Bearer '.length).trim() + return token.length > 0 ? token : undefined + } + + if (!cookieHeader) { + return undefined + } + + for (const part of cookieHeader.split(';')) { + const [name, ...valueParts] = part.trim().split('=') + if (name === 'admin_session') { + const value = valueParts.join('=').trim() + return value.length > 0 ? value : undefined + } + } + + return undefined +} diff --git a/test/unit/routes/admin.spec.ts b/test/unit/routes/admin.spec.ts new file mode 100644 index 00000000..c5732e20 --- /dev/null +++ b/test/unit/routes/admin.spec.ts @@ -0,0 +1,200 @@ +import axios from 'axios' +import { expect } from 'chai' +import express from 'express' +import Sinon from 'sinon' + +import * as getAdminHealthControllerFactory from '../../../src/factories/controllers/get-admin-health-controller-factory' +import { hashAdminPassword } from '../../../src/utils/admin-password' +import * as rateLimiterMiddleware from '../../../src/handlers/request-handlers/rate-limiter-middleware' +import * as settingsFactory from '../../../src/factories/settings-factory' + +describe('admin router', () => { + const originalSecret = process.env.SECRET + const originalAdminPassword = process.env.ADMIN_PASSWORD + let createGetAdminHealthControllerStub: Sinon.SinonStub + let createSettingsStub: Sinon.SinonStub + let rateLimiterMiddlewareStub: Sinon.SinonStub + let server: any + + const loadAdminRouter = () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + delete require.cache[require.resolve('../../../src/routes/admin/index')] + // eslint-disable-next-line @typescript-eslint/no-var-requires + delete require.cache[require.resolve('../../../src/routes/admin')] + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('../../../src/routes/admin').default + } + + const startServer = async (settings: Record) => { + createGetAdminHealthControllerStub = Sinon.stub(getAdminHealthControllerFactory, 'createGetAdminHealthController').returns({ + handleRequest: async (_request: any, response: any) => { + response + .status(200) + .setHeader('content-type', 'application/json') + .send( + JSON.stringify({ + status: 'ok', + uptimeSeconds: 1, + worker: { type: 'primary' }, + database: { ok: true }, + redis: { ok: true }, + }), + ) + }, + } as any) + createSettingsStub = Sinon.stub(settingsFactory, 'createSettings').returns(settings as any) + rateLimiterMiddlewareStub = Sinon.stub(rateLimiterMiddleware, 'rateLimiterMiddleware').callsFake(async (_request, _response, next) => { + next() + }) + const router = loadAdminRouter() + const app = express() + app.use('/admin', router) + + server = await new Promise((resolve) => { + const listeningServer = app.listen(0, () => resolve(listeningServer)) + }) + + return `http://127.0.0.1:${server.address().port}/admin` + } + + const stopServer = async () => { + createGetAdminHealthControllerStub?.restore() + createSettingsStub?.restore() + rateLimiterMiddlewareStub?.restore() + delete require.cache[require.resolve('../../../src/routes/admin/index')] + delete require.cache[require.resolve('../../../src/routes/admin')] + + if (server) { + await new Promise((resolve, reject) => { + server.close((error: Error | undefined) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + server = undefined + } + } + + before(() => { + process.env.SECRET = 'test-admin-secret-value' + }) + + after(() => { + if (originalSecret === undefined) { + delete process.env.SECRET + } else { + process.env.SECRET = originalSecret + } + + if (originalAdminPassword === undefined) { + delete process.env.ADMIN_PASSWORD + } else { + process.env.ADMIN_PASSWORD = originalAdminPassword + } + }) + + afterEach(async () => { + delete process.env.ADMIN_PASSWORD + await stopServer() + }) + + it('returns 404 when admin is disabled', async () => { + const baseUrl = await startServer({ admin: { enabled: false } }) + + const response = await axios.get(`${baseUrl}/health`, { validateStatus: () => true }) + + expect(response.status).to.equal(404) + expect(response.data).to.equal('Not Found') + }) + + it('returns 401 for protected routes without a session', async () => { + const baseUrl = await startServer({ admin: { enabled: true } }) + + const sessionResponse = await axios.get(`${baseUrl}/session`, { validateStatus: () => true }) + const healthResponse = await axios.get(`${baseUrl}/health`, { validateStatus: () => true }) + + expect(sessionResponse.status).to.equal(401) + expect(healthResponse.status).to.equal(401) + }) + + it('rejects invalid login credentials', async () => { + process.env.ADMIN_PASSWORD = 'correct-password' + const baseUrl = await startServer({ admin: { enabled: true } }) + + const response = await axios.post( + `${baseUrl}/login`, + { password: 'wrong-password' }, + { + headers: { 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(response.status).to.equal(401) + }) + + it('authenticates with ADMIN_PASSWORD and exposes session and health', async () => { + process.env.ADMIN_PASSWORD = 'correct-password' + const baseUrl = await startServer({ admin: { enabled: true, sessionTtlSeconds: 3600 } }) + + const loginResponse = await axios.post( + `${baseUrl}/login`, + { password: 'correct-password' }, + { + headers: { 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(loginResponse.status).to.equal(200) + expect(loginResponse.data.authenticated).to.equal(true) + expect(loginResponse.data.expiresAt).to.be.a('number') + expect(loginResponse.headers['set-cookie']?.[0]).to.include('admin_session=') + + const cookie = loginResponse.headers['set-cookie']?.[0]?.split(';')[0] + + const sessionResponse = await axios.get(`${baseUrl}/session`, { + headers: { cookie }, + validateStatus: () => true, + }) + expect(sessionResponse.status).to.equal(200) + expect(sessionResponse.data.authenticated).to.equal(true) + + const healthResponse = await axios.get(`${baseUrl}/health`, { + headers: { cookie }, + validateStatus: () => true, + }) + expect(healthResponse.status).to.equal(200) + expect(healthResponse.data).to.include.keys('status', 'uptimeSeconds', 'worker', 'database', 'redis') + }) + + it('authenticates with passwordHash from settings', async () => { + const passwordHash = hashAdminPassword('settings-password') + const baseUrl = await startServer({ + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + + const loginResponse = await axios.post( + `${baseUrl}/login`, + { password: 'settings-password' }, + { + headers: { 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(loginResponse.status).to.equal(200) + + const token = loginResponse.headers['set-cookie']?.[0]?.split(';')[0]?.split('=')[1] + const sessionResponse = await axios.get(`${baseUrl}/session`, { + headers: { Authorization: `Bearer ${token}` }, + validateStatus: () => true, + }) + + expect(sessionResponse.status).to.equal(200) + }) +}) diff --git a/test/unit/utils/admin-session.spec.ts b/test/unit/utils/admin-session.spec.ts new file mode 100644 index 00000000..7ce3907e --- /dev/null +++ b/test/unit/utils/admin-session.spec.ts @@ -0,0 +1,32 @@ +import { expect } from 'chai' + +import { createAdminSessionToken, getAdminSessionTokenFromRequest, isValidAdminSessionToken } from '../../../src/utils/admin-session' + +describe('admin-session', () => { + const originalSecret = process.env.SECRET + + before(() => { + process.env.SECRET = 'test-admin-secret-value' + }) + + after(() => { + if (originalSecret === undefined) { + delete process.env.SECRET + } else { + process.env.SECRET = originalSecret + } + }) + + it('creates and validates a signed session token', () => { + const expiresAt = Math.floor(Date.now() / 1000) + 3600 + const token = createAdminSessionToken(expiresAt) + + expect(isValidAdminSessionToken(token)).to.equal(true) + expect(isValidAdminSessionToken(`${expiresAt}.deadbeef`)).to.equal(false) + }) + + it('reads bearer and cookie session tokens from request headers', () => { + expect(getAdminSessionTokenFromRequest('Bearer abc.def', undefined)).to.equal('abc.def') + expect(getAdminSessionTokenFromRequest(undefined, 'admin_session=abc.def; other=value')).to.equal('abc.def') + }) +})