From 7faf67d933307aa6ab8c567213589cf69110fbe9 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 31 Mar 2026 14:43:13 -0400 Subject: [PATCH 1/3] feat: add admin bootstrapping --- .env.example | 10 + src/controllers/bootstrap.ts | 68 +++++ src/controllers/magicLinks.ts | 90 +++---- src/controllers/otp.ts | 159 +++--------- src/controllers/registration.ts | 34 +-- src/controllers/webauthn.ts | 142 +++-------- src/lib/bootstrapCookie.ts | 32 +++ src/lib/defineRoute.ts | 2 +- src/lib/zodExample.ts | 1 + ...20260330213238-admin-bootstrap-invites.cjs | 75 ++++++ src/models/bootstrapInvites.ts | 108 ++++++++ src/routes/bootstrap.routes.ts | 39 +++ src/schemas/bootstrap.schema.ts | 31 +++ src/schemas/otp.responses.ts | 2 - src/schemas/registration.requests.ts | 1 + src/services/authEventService.ts | 2 + src/services/bootstrapPromotionService.ts | 173 +++++++++++++ src/services/bootstrapService.ts | 158 ++++++++++++ src/services/messagingService.ts | 8 + src/services/sessionIssuance.ts | 82 ++++++ tests/integration/bootstrap/bootstrap.spec.ts | 139 ++++++++++ tests/integration/otp/otp.spec.ts | 33 +-- .../integration/registration/register.spec.ts | 1 - .../middleware/attachAuthMiddleware.spec.ts | 1 + tests/unit/services/bootstrap.spec.ts | 175 +++++++++++++ .../bootstrapPromotionService.spec.ts | 240 ++++++++++++++++++ .../unit/services/sessionIssueService.spec.ts | 220 ++++++++++++++++ 27 files changed, 1691 insertions(+), 335 deletions(-) create mode 100644 src/controllers/bootstrap.ts create mode 100644 src/lib/bootstrapCookie.ts create mode 100644 src/migrations/20260330213238-admin-bootstrap-invites.cjs create mode 100644 src/models/bootstrapInvites.ts create mode 100644 src/routes/bootstrap.routes.ts create mode 100644 src/schemas/bootstrap.schema.ts create mode 100644 src/services/bootstrapPromotionService.ts create mode 100644 src/services/bootstrapService.ts create mode 100644 src/services/sessionIssuance.ts create mode 100644 tests/integration/bootstrap/bootstrap.spec.ts create mode 100644 tests/unit/services/bootstrap.spec.ts create mode 100644 tests/unit/services/bootstrapPromotionService.spec.ts create mode 100644 tests/unit/services/sessionIssueService.spec.ts diff --git a/.env.example b/.env.example index 9150c45..5d94a0b 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,13 @@ JWKS_ACTIVE_KID=dev-main # WEBAUTHN RPID=localhost ORIGINS=http://localhost:5001 + +# ADMIN BOOTSTRAP +# Enables bootstrap feature +SEAMLESS_BOOTSTRAP_ENABLED=true + +# Secret used to authorize bootstrap invite creation +SEAMLESS_BOOTSTRAP_SECRET=dev-bootstrap-secret-123 + +# How long the invite (and cookie) is valid +SEAMLESS_BOOTSTRAP_TTL_MINUTES=15 \ No newline at end of file diff --git a/src/controllers/bootstrap.ts b/src/controllers/bootstrap.ts new file mode 100644 index 0000000..e7d07b7 --- /dev/null +++ b/src/controllers/bootstrap.ts @@ -0,0 +1,68 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { Request, Response } from 'express'; + +import { + assertBootstrapAllowed, + assertBootstrapSecret, + BootstrapError, + createAdminBootstrapInvite, +} from '../services/bootstrapService.js'; + +function getBearerToken(req: Request): string | undefined { + const auth = req.header('authorization'); + if (!auth) return undefined; + + const [scheme, token] = auth.split(' '); + if (scheme?.toLowerCase() !== 'bearer') return undefined; + + return token; +} + +export async function createAdminBootstrapInviteHandler(req: Request, res: Response) { + try { + const bearerToken = getBearerToken(req); + + assertBootstrapSecret(bearerToken); + await assertBootstrapAllowed(); + + const { email } = req.body; + + const result = await createAdminBootstrapInvite({ + email, + createdIp: req.ip ?? null, + createdUserAgent: req.get('user-agent') ?? null, + }); + + return res.status(201).json({ + success: true, + data: { + url: result.registrationUrl, + expiresAt: result.expiresAt.toISOString(), + token: result.token, + }, + }); + } catch (error) { + if (error instanceof BootstrapError) { + return res.status(error.status).json({ + success: false, + error: { + code: error.code, + message: error.message, + }, + }); + } + + return res.status(500).json({ + success: false, + error: { + code: 'BOOTSTRAP_INTERNAL_ERROR', + message: 'An unexpected error occurred.', + }, + }); + } +} diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index 9079dd6..0488174 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -9,22 +9,16 @@ import { Request, Response } from 'express'; import { Op } from 'sequelize'; import { getSystemConfig } from '../config/getSystemConfig.js'; -import { setAuthCookies } from '../lib/cookie.js'; -import { generateRefreshToken, hashRefreshToken, signAccessToken } from '../lib/token.js'; import { AuthEvent } from '../models/authEvents.js'; import { MagicLinkToken } from '../models/magicLinks.js'; -import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; +import { maybePromoteBootstrapAdmin } from '../services/bootstrapPromotionService.js'; import { sendMagicLinkEmail } from '../services/messagingService.js'; +import { issueSessionAndRespond } from '../services/sessionIssuance.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; -import { - computeSessionTimes, - hashDeviceFingerprint, - hashSha256, - parseDurationToSeconds, -} from '../utils/utils.js'; +import { hashDeviceFingerprint, hashSha256 } from '../utils/utils.js'; const logger = getLogger('magic-links'); @@ -193,59 +187,43 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) { req, }); - const refreshToken = generateRefreshToken(); - const refreshTokenHash = await hashRefreshToken(refreshToken); - const { expiresAt, idleExpiresAt } = computeSessionTimes(); - - const session = await Session.create({ - userId: user.id, - infraId: process.env.APP_ID!, - mode: AUTH_MODE, - refreshTokenHash, - userAgent: req.get('user-agent'), - ipAddress: req.ip, - expiresAt, - idleExpiresAt, - lastUsedAt: undefined, - }); - - const token = await signAccessToken(session.id, user.id, user.roles); - user.challenge = ''; user.verified = true; await user.save(); - if (token && refreshToken) { - await AuthEvent.create({ - user_id: user.id, - type: 'registration_success', - ip_address: req.ip, - user_agent: req.headers['user-agent'], - metadata: {}, - }); - - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { accessToken: token, refreshToken }); - res.status(200).json({ message: 'Success' }); - return; - } - - const { access_token_ttl, refresh_token_ttl } = await getSystemConfig(); - - return res.status(200).json({ - message: 'Success', - token, - refreshToken, - sub: user.id, - roles: user.roles, + const bootstrapResult = await maybePromoteBootstrapAdmin({ + user, + req, + completionMethod: 'magic_link_fallback', + }); + + if (bootstrapResult.promoted) { + logger.info(`Bootstrap admin granted to ${user.email}`); + } + + await AuthEvent.create({ + user_id: user.id, + type: 'registration_success', + ip_address: req.ip, + user_agent: req.headers['user-agent'], + metadata: {}, + }); + + await issueSessionAndRespond({ + user: { + id: user.id, email: user.email, phone: user.phone, - ttl: parseDurationToSeconds(access_token_ttl || '15m'), - refreshTtl: parseDurationToSeconds(refresh_token_ttl || '1h'), - }); - } - } + roles: user.roles ?? [], + }, + req, + res, + authMode: AUTH_MODE, + clearBootstrap: true, + }); - return res.status(204).json({ message: 'Not verified.' }); + return res.json({ message: 'Success' }); + } + return res.status(204).json({ message: 'Success' }); } diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index e2df058..4b5f376 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -7,14 +7,9 @@ import { Request, Response } from 'express'; import { setAuthCookies } from '../lib/cookie.js'; -import { - generateRefreshToken, - hashRefreshToken, - signAccessToken, - signEphemeralToken, -} from '../lib/token.js'; -import { Session } from '../models/sessions.js'; +import { signEphemeralToken } from '../lib/token.js'; import { AuthEventService } from '../services/authEventService.js'; +import { issueSessionAndRespond } from '../services/sessionIssuance.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; import { @@ -23,7 +18,7 @@ import { verifyEmailOTP, verifyPhoneOTP, } from '../utils/otp.js'; -import { computeSessionTimes, isValidEmail, isValidPhoneNumber } from '../utils/utils.js'; +import { isValidEmail, isValidPhoneNumber } from '../utils/utils.js'; const logger = getLogger('otp'); const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; @@ -211,7 +206,6 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { req, metadata: { reason: 'User verified their phone number' }, }); - let token, refreshToken, refreshTokenHash; if (user.phoneVerified && user.emailVerified && user.verified) { logger.info(`${phone} is fully verified. Logging in...`); @@ -222,32 +216,7 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { metadata: { reason: 'User completed verification of phone and email' }, }); - refreshToken = generateRefreshToken(); - refreshTokenHash = await hashRefreshToken(refreshToken); - const { expiresAt, idleExpiresAt } = computeSessionTimes(); - - const session = await Session.create({ - userId: user.id, - infraId: process.env.APP_ID!, - mode: AUTH_MODE, - refreshTokenHash, - userAgent: req.get('user-agent'), - ipAddress: req.ip, - expiresAt, - idleExpiresAt, - lastUsedAt: undefined, - }); - - token = await signAccessToken(session.id, user.id, user.roles); - } - - if (token && refreshToken) { - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { accessToken: token, refreshToken }); - return res.status(200).json({ message: 'Success' }); - } - - return res.status(200).json({ message: 'Success', token, refreshToken }); + return res.status(200).json({ message: 'Success' }); } res.json({ message: 'Success' }); } else { @@ -321,7 +290,6 @@ export const verifyEmail = async (req: Request, res: Response) => { req, metadata: { reason: 'User verified their email number' }, }); - let token, refreshToken, refreshTokenHash; if (user.phoneVerified && user.emailVerified && user.verified) { logger.info(`${email} is fully verified. Logging in...`); @@ -333,32 +301,19 @@ export const verifyEmail = async (req: Request, res: Response) => { metadata: { reason: 'User completed verification of phone and email' }, }); - refreshToken = generateRefreshToken(); - refreshTokenHash = await hashRefreshToken(refreshToken); - const { expiresAt, idleExpiresAt } = computeSessionTimes(); - - const session = await Session.create({ - userId: user.id, - infraId: process.env.APP_ID!, - mode: AUTH_MODE, - refreshTokenHash, - userAgent: req.get('user-agent'), - ipAddress: req.ip, - expiresAt, - idleExpiresAt, - lastUsedAt: undefined, + await issueSessionAndRespond({ + user: { + id: user.id, + email: user.email, + phone: user.phone, + roles: user.roles ?? [], + }, + req, + res, + authMode: AUTH_MODE, }); - token = await signAccessToken(session.id, user.id, user.roles); - } - - if (token && refreshToken) { - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { accessToken: token, refreshToken }); - return res.status(200).json({ message: 'Success' }); - } - - return res.status(200).json({ message: 'Success', token, refreshToken }); + return; } return res.json({ message: 'Success' }); } else { @@ -417,8 +372,6 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { req, }); - let token, refreshToken, refreshTokenHash; - if (user.phoneVerified && user.emailVerified && user.verified) { logger.info(`${email} is fully verified. Logging in...`); @@ -429,37 +382,19 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { metadata: { reason: 'User completed verification of phone and email' }, }); - refreshToken = generateRefreshToken(); - refreshTokenHash = await hashRefreshToken(refreshToken); - const { expiresAt, idleExpiresAt } = computeSessionTimes(); - - const session = await Session.create({ - userId: user.id, - infraId: process.env.APP_ID!, - mode: AUTH_MODE, - refreshTokenHash, - userAgent: req.get('user-agent'), - ipAddress: req.ip, - expiresAt, - idleExpiresAt, - lastUsedAt: undefined, + await issueSessionAndRespond({ + user: { + id: user.id, + email: user.email, + phone: user.phone, + roles: user.roles ?? [], + }, + req, + res, + authMode: AUTH_MODE, }); - token = await signAccessToken(session.id, user.id, user.roles); - } - - if (token && refreshToken) { - try { - await user.update({ lastLogin: new Date() }); - } catch (error) { - logger.warn(`An error occured saving user last login - ${error}`); - } - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { accessToken: token, refreshToken }); - return res.status(200).json({ message: 'Success' }); - } - - return res.status(200).json({ message: 'Success', token, refreshToken }); + return; } return res.json({ message: 'Success' }); } else { @@ -539,8 +474,6 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { req, }); - let token, refreshToken, refreshTokenHash; - if (user.phoneVerified && user.emailVerified && user.verified) { logger.info(`${email} is fully verified. Logging in...`); @@ -551,37 +484,19 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { metadata: { reason: 'User completed verification of phone and email' }, }); - refreshToken = generateRefreshToken(); - refreshTokenHash = await hashRefreshToken(refreshToken); - const { expiresAt, idleExpiresAt } = computeSessionTimes(); - - const session = await Session.create({ - userId: user.id, - infraId: process.env.APP_ID!, - mode: AUTH_MODE, - refreshTokenHash, - userAgent: req.get('user-agent'), - ipAddress: req.ip, - expiresAt, - idleExpiresAt, - lastUsedAt: undefined, + await issueSessionAndRespond({ + user: { + id: user.id, + email: user.email, + phone: user.phone, + roles: user.roles ?? [], + }, + req, + res, + authMode: AUTH_MODE, }); - token = await signAccessToken(session.id, user.id, user.roles); - } - - if (token && refreshToken) { - try { - await user.update({ lastLogin: new Date() }); - } catch (error) { - logger.warn(`An error occured saving user last login - ${error}`); - } - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { accessToken: token, refreshToken }); - return res.status(200).json({ message: 'Success' }); - } - - return res.status(200).json({ message: 'Success', token, refreshToken }); + return; } return res.json({ message: 'Success' }); } else { diff --git a/src/controllers/registration.ts b/src/controllers/registration.ts index ace99d8..fd475fd 100644 --- a/src/controllers/registration.ts +++ b/src/controllers/registration.ts @@ -8,6 +8,7 @@ import { Request, Response } from 'express'; import { Op } from 'sequelize'; import { getSystemConfig } from '../config/getSystemConfig.js'; +import { setBootstrapCookie } from '../lib/bootstrapCookie.js'; import { setAuthCookies } from '../lib/cookie.js'; import { signEphemeralToken } from '../lib/token.js'; import { AuthEvent } from '../models/authEvents.js'; @@ -21,35 +22,18 @@ const logger = getLogger('registration'); const AUTH_MODE = process.env.AUTH_MODE; export const register = async (req: Request, res: Response) => { - const { email, phone } = req.body; + const { email, phone, bootstrapToken } = req.body; + + if (bootstrapToken && bootstrapToken.length > 10) { + setBootstrapCookie(res, bootstrapToken); + + logger.info('Bootstrap token stored in cookie for registration flow'); + } + const systemConfig = await getSystemConfig(); logger.info(`Registering phone and email account`); try { - // TODO: These checks can go away thanks to the zod refactor - if (!email) { - logger.error(`Missing email`); - AuthEventService.log({ - userId: null, - type: 'registration_suspicious', - req, - metadata: { reason: 'Missing required email.' }, - }); - return res.status(400).json({ message: 'Invalid data.' }); - } - - // TODO: These checks can go away thanks to the zod refactor - if (!phone) { - logger.error(`Missing phone`); - AuthEventService.log({ - userId: null, - type: 'registration_suspicious', - req, - metadata: { reason: 'Missing required phone.' }, - }); - return res.status(400).json({ message: 'Invalid data.' }); - } - if (!isValidEmail(email) || !isValidPhoneNumber(phone)) { logger.error(`Invalid email or phone provided: ${email} - ${phone}`); AuthEventService.log({ diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index 5167ea0..dfdad54 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -16,16 +16,14 @@ import base64url from 'base64url'; import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; -import { clearAuthCookies, setAuthCookies } from '../lib/cookie.js'; -import { generateRefreshToken, hashRefreshToken, signAccessToken } from '../lib/token.js'; import { AuthEvent } from '../models/authEvents.js'; import { Credential } from '../models/credentials.js'; -import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; +import { maybePromoteBootstrapAdmin } from '../services/bootstrapPromotionService.js'; +import { issueSessionAndRespond } from '../services/sessionIssuance.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; -import { computeSessionTimes, parseDurationToSeconds } from '../utils/utils.js'; const logger = getLogger('webauthn'); const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; @@ -230,64 +228,38 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { verified: true, }); - logger.info(`Passkey credential saved successfully for user: ${verifiedUser.email}`); + const bootstrapResult = await maybePromoteBootstrapAdmin({ + user, + req, + completionMethod: 'webauthn_registration', + }); + + if (bootstrapResult.promoted) { + logger.info(`Bootstrap admin granted to ${user.email}`); + } await AuthEvent.create({ user_id: user.id, - type: 'credential_created', + type: 'registration_success', ip_address: req.ip, user_agent: req.headers['user-agent'], - metadata: { reason: 'Registration' }, - }); - - const refreshToken = generateRefreshToken(); - const refreshTokenHash = await hashRefreshToken(refreshToken); - const { expiresAt, idleExpiresAt } = computeSessionTimes(); - - const session = await Session.create({ - userId: user.id, - infraId: process.env.APP_ID!, - mode: AUTH_MODE, - refreshTokenHash, - userAgent: req.get('user-agent'), - ipAddress: req.ip, - expiresAt, - idleExpiresAt, - lastUsedAt: undefined, + metadata: {}, }); - const token = await signAccessToken(session.id, user.id, user.roles); - - if (token && refreshToken) { - await AuthEvent.create({ - user_id: user.id, - type: 'registration_success', - ip_address: req.ip, - user_agent: req.headers['user-agent'], - metadata: {}, - }); - - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { accessToken: token, refreshToken }); - res.status(200).json({ message: 'Success' }); - return; - } - - const { access_token_ttl, refresh_token_ttl } = await getSystemConfig(); - - return res.status(200).json({ - message: 'Success', - token, - refreshToken, - sub: user.id, - roles: user.roles, + await issueSessionAndRespond({ + user: { + id: user.id, email: user.email, phone: user.phone, - ttl: parseDurationToSeconds(access_token_ttl || '15m'), - refreshTtl: parseDurationToSeconds(refresh_token_ttl || '1h'), - }); - } - return res.status(500).json({ error: 'Unknown error verifying passkey' }); + roles: user.roles ?? [], + }, + req, + res, + authMode: AUTH_MODE, + clearBootstrap: true, + }); + + return; } catch (err) { logger.error(`Error in verifyWebAuthnRegistration: ${err}`); return res.status(500).json({ error: 'Unknown error verifying passkey' }); @@ -486,63 +458,19 @@ const verifyWebAuthn = async (req: Request, res: Response) => { metadata: { reason: 'Successful login' }, }); - const refreshToken = generateRefreshToken(); - const refreshTokenHash = await hashRefreshToken(refreshToken); - const { expiresAt, idleExpiresAt } = computeSessionTimes(); - - const session = await Session.create({ - userId: user.id, - infraId: process.env.APP_ID!, - mode: AUTH_MODE, - refreshTokenHash, - userAgent: req.get('user-agent'), - ipAddress: req.ip, - expiresAt, - idleExpiresAt, - lastUsedAt: undefined, - }); - - const token = await signAccessToken(session.id, user.id, user.roles); - - user.challenge = ''; - user.lastLogin = new Date(); - - await user.save(); - - await AuthEventService.loginSuccess(user.id, req); - - if (token && refreshToken) { - clearAuthCookies(res); - - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { accessToken: token, refreshToken }); - res.status(200).json({ message: 'Success' }); - return; - } - - const { access_token_ttl, refresh_token_ttl } = await getSystemConfig(); - - return res.status(200).json({ - message: 'Success', - token, - refreshToken, - sub: user.id, - roles: user.roles, + await issueSessionAndRespond({ + user: { + id: user.id, email: user.email, phone: user.phone, - ttl: parseDurationToSeconds(access_token_ttl || '15m'), - refreshTtl: parseDurationToSeconds(refresh_token_ttl || '1h'), - }); - } - } else { - await AuthEvent.create({ - user_id: null, - type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], - metadata: { reason: 'Verification failed' }, + roles: user.roles ?? [], + }, + req, + res, + authMode: AUTH_MODE, + clearExistingCookies: true, }); - res.status(401).json({ error: 'Authentication failed' }); + return; } } catch (error) { diff --git a/src/lib/bootstrapCookie.ts b/src/lib/bootstrapCookie.ts new file mode 100644 index 0000000..c34b79b --- /dev/null +++ b/src/lib/bootstrapCookie.ts @@ -0,0 +1,32 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { Request, Response } from 'express'; + +const COOKIE_NAME = 'seamless_bootstrap_token'; + +export function setBootstrapCookie(res: Response, token: string) { + res.cookie(COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + maxAge: 15 * 60 * 1000, // 15 minutes + path: '/', + }); +} + +export function getBootstrapCookie(req: Request): string | null { + return req.cookies?.[COOKIE_NAME] ?? null; +} + +export function clearBootstrapCookie(res: Response) { + res.clearCookie(COOKIE_NAME, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + path: '/', + }); +} diff --git a/src/lib/defineRoute.ts b/src/lib/defineRoute.ts index edf243a..46ee861 100644 --- a/src/lib/defineRoute.ts +++ b/src/lib/defineRoute.ts @@ -53,7 +53,7 @@ function buildResponses( }; } - if (!(response instanceof Object) || !('200' in response)) { + if (!(response instanceof Object)) { const schema = response as ZodTypeAny; return { diff --git a/src/lib/zodExample.ts b/src/lib/zodExample.ts index 1e3a3c3..f1ddffd 100644 --- a/src/lib/zodExample.ts +++ b/src/lib/zodExample.ts @@ -12,6 +12,7 @@ export function generateExample(schema: any): unknown { if (schema instanceof z.ZodNumber) return 0; if (schema instanceof z.ZodBoolean) return true; + if (schema instanceof z.ZodEmail) return 'example@fellscode.com'; if (schema instanceof z.ZodArray) { const elementSchema = schema.def.type; return [generateExample(elementSchema)]; diff --git a/src/migrations/20260330213238-admin-bootstrap-invites.cjs b/src/migrations/20260330213238-admin-bootstrap-invites.cjs new file mode 100644 index 0000000..79a5fa5 --- /dev/null +++ b/src/migrations/20260330213238-admin-bootstrap-invites.cjs @@ -0,0 +1,75 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('bootstrap_invites', { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + defaultValue: Sequelize.literal('gen_random_uuid()'), + }, + email: { + type: Sequelize.STRING(320), + allowNull: false, + }, + role: { + type: Sequelize.ENUM('admin'), + allowNull: false, + defaultValue: 'admin', + }, + token_hash: { + type: Sequelize.STRING(255), + allowNull: false, + unique: true, + }, + expires_at: { + type: Sequelize.DATE, + allowNull: false, + }, + consumed_at: { + type: Sequelize.DATE, + allowNull: true, + }, + created_by: { + type: Sequelize.STRING(64), + allowNull: false, + defaultValue: 'bootstrap', + }, + created_ip: { + type: Sequelize.STRING(64), + allowNull: true, + }, + created_user_agent: { + type: Sequelize.TEXT, + allowNull: true, + }, + last_sent_at: { + type: Sequelize.DATE, + allowNull: true, + }, + attempt_count: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + created_at: { + allowNull: false, + type: Sequelize.DATE, + }, + updated_at: { + allowNull: false, + type: Sequelize.DATE, + }, + }); + + await queryInterface.addIndex('bootstrap_invites', ['email']); + await queryInterface.addIndex('bootstrap_invites', ['expires_at']); + await queryInterface.addIndex('bootstrap_invites', ['consumed_at']); + }, + + async down(queryInterface) { + await queryInterface.dropTable('bootstrap_invites'); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_bootstrap_invites_role";'); + }, +}; diff --git a/src/models/bootstrapInvites.ts b/src/models/bootstrapInvites.ts new file mode 100644 index 0000000..9812676 --- /dev/null +++ b/src/models/bootstrapInvites.ts @@ -0,0 +1,108 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { + CreationOptional, + DataTypes, + InferAttributes, + InferCreationAttributes, + Model, + Sequelize, +} from 'sequelize'; + +export class BootstrapInvite extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare email: string; + declare role: 'admin'; + declare tokenHash: string; + declare expiresAt: Date; + declare consumedAt: Date | null; + declare createdBy: string; + declare createdIp: string | null; + declare createdUserAgent: string | null; + declare lastSentAt: Date | null; + declare attemptCount: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; +} + +const initializeBootstrapInviteModel = (sequelize: Sequelize) => { + BootstrapInvite.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + email: { + type: DataTypes.STRING(320), + allowNull: false, + validate: { + isEmail: true, + }, + }, + role: { + type: DataTypes.ENUM('admin'), + allowNull: false, + defaultValue: 'admin', + }, + tokenHash: { + type: DataTypes.STRING(255), + allowNull: false, + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false, + }, + consumedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + createdBy: { + type: DataTypes.STRING(64), + allowNull: false, + defaultValue: 'bootstrap', + }, + createdIp: { + type: DataTypes.STRING(64), + allowNull: true, + }, + createdUserAgent: { + type: DataTypes.TEXT, + allowNull: true, + }, + lastSentAt: { + type: DataTypes.DATE, + allowNull: true, + }, + attemptCount: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, + { + sequelize, + modelName: 'BootstrapInvite', + tableName: 'bootstrap_invites', + underscored: true, + indexes: [ + { fields: ['email'] }, + { fields: ['expires_at'] }, + { fields: ['consumed_at'] }, + { unique: true, fields: ['token_hash'] }, + ], + }, + ); + return BootstrapInvite; +}; + +export default initializeBootstrapInviteModel; diff --git a/src/routes/bootstrap.routes.ts b/src/routes/bootstrap.routes.ts new file mode 100644 index 0000000..70f40a7 --- /dev/null +++ b/src/routes/bootstrap.routes.ts @@ -0,0 +1,39 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { createAdminBootstrapInviteHandler } from '../controllers/bootstrap.js'; +import { createRouter } from '../lib/createRouter.js'; +import { + BootstrapAdminInviteBodySchema, + BootstrapAdminInviteResponseSchema, + BootstrapErrorResponseSchema, +} from '../schemas/bootstrap.schema.js'; + +const bootstrapRouter = createRouter(''); + +bootstrapRouter.post( + '/internal/bootstrap/admin-invite', + { + summary: 'Create a one-time bootstrap admin invite', + description: + 'Internal bootstrap endpoint used to create the first admin invite before any admin exists.', + tags: ['Internal Bootstrap'], + schemas: { + body: BootstrapAdminInviteBodySchema, + response: { + 201: BootstrapAdminInviteResponseSchema, + 401: BootstrapErrorResponseSchema, + 403: BootstrapErrorResponseSchema, + 409: BootstrapErrorResponseSchema, + 410: BootstrapErrorResponseSchema, + 500: BootstrapErrorResponseSchema, + }, + }, + }, + createAdminBootstrapInviteHandler, +); + +export default bootstrapRouter.router; diff --git a/src/schemas/bootstrap.schema.ts b/src/schemas/bootstrap.schema.ts new file mode 100644 index 0000000..272d1f1 --- /dev/null +++ b/src/schemas/bootstrap.schema.ts @@ -0,0 +1,31 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +export const BootstrapAdminInviteBodySchema = z.object({ + email: z.email().max(320), +}); + +export const BootstrapAdminInviteResponseSchema = z.object({ + success: z.literal(true), + data: z.object({ + url: z.url(), + expiresAt: z.iso.datetime(), + token: z.string().min(32), + }), +}); + +export const BootstrapErrorResponseSchema = z.object({ + success: z.literal(false), + error: z.object({ + code: z.string(), + message: z.string(), + }), +}); + +export type BootstrapAdminInviteBody = z.infer; +export type BootstrapAdminInviteResponse = z.infer; diff --git a/src/schemas/otp.responses.ts b/src/schemas/otp.responses.ts index c0db441..37f49a8 100644 --- a/src/schemas/otp.responses.ts +++ b/src/schemas/otp.responses.ts @@ -8,6 +8,4 @@ import { z } from 'zod'; export const OTPVerifyTokenSuccessSchema = z.object({ message: z.string(), - token: z.string().optional(), - refreshTokenHash: z.string().optional(), }); diff --git a/src/schemas/registration.requests.ts b/src/schemas/registration.requests.ts index a533f56..e83a646 100644 --- a/src/schemas/registration.requests.ts +++ b/src/schemas/registration.requests.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; export const RegistrationRequestSchema = z.object({ + bootstrapToken: z.string().optional(), email: z.email(), phone: z.string(), }); diff --git a/src/services/authEventService.ts b/src/services/authEventService.ts index 8e205b2..6db59c3 100644 --- a/src/services/authEventService.ts +++ b/src/services/authEventService.ts @@ -16,6 +16,8 @@ export type AuthEventType = | 'bearer_token_failed' | 'bearer_token_success' | 'bearer_token_suspicious' + | 'bootstrap_admin_granted' + | 'bootstrap_admin_check_skipped' | 'cookie_token_failed' | 'cookie_token_success' | 'cookie_token_suspicious' diff --git a/src/services/bootstrapPromotionService.ts b/src/services/bootstrapPromotionService.ts new file mode 100644 index 0000000..516e14f --- /dev/null +++ b/src/services/bootstrapPromotionService.ts @@ -0,0 +1,173 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import crypto from 'crypto'; +import { Request } from 'express'; +import { literal, Transaction } from 'sequelize'; + +import { getBootstrapCookie } from '../lib/bootstrapCookie.js'; +import { BootstrapInvite } from '../models/bootstrapInvites.js'; +import { getSequelize } from '../models/index.js'; +import { User } from '../models/users.js'; +import getLogger from '../utils/logger.js'; +import { AuthEventService } from './authEventService.js'; + +type CompletionMethod = 'webauthn_registration' | 'magic_link_fallback' | 'email_otp' | 'phone_otp'; + +type PromotionResult = + | { promoted: true; reason: 'success' } + | { + promoted: false; + reason: + | 'bootstrap_disabled' + | 'missing_token' + | 'invalid_token' + | 'invite_expired' + | 'invite_consumed' + | 'email_mismatch' + | 'admin_exists' + | 'already_admin'; + }; + +const logger = getLogger('bootstrapPromotionService'); + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function hashBootstrapToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +function isBootstrapEnabled(): boolean { + return process.env.SEAMLESS_BOOTSTRAP_ENABLED === 'true'; +} + +function userHasAdminRole(user: User): boolean { + return Array.isArray(user.roles) && user.roles.includes('admin'); +} + +function addAdminRole(user: User): void { + const currentRoles = Array.isArray(user.roles) ? user.roles : []; + if (!currentRoles.includes('admin')) { + user.roles = [...currentRoles, 'admin']; + } +} + +export async function maybePromoteBootstrapAdmin(params: { + user: User; + req: Request; + completionMethod: CompletionMethod; +}): Promise { + const { user, req, completionMethod } = params; + logger.debug('checking for promotion'); + + async function logSkip(reason: string) { + await AuthEventService.log({ + userId: user.id, + type: 'bootstrap_admin_check_skipped', + req, + metadata: { reason, completionMethod }, + }); + } + + if (!isBootstrapEnabled()) { + logger.info('Bootstrap is not enabled'); + await AuthEventService.log({ + userId: user.id, + type: 'bootstrap_admin_check_skipped', + req, + metadata: { reason: 'bootstrap disabled', completionMethod }, + }); + return { promoted: false, reason: 'bootstrap_disabled' }; + } + + if (userHasAdminRole(user)) { + logger.info('User is already and admin'); + await AuthEventService.log({ + userId: user.id, + type: 'bootstrap_admin_check_skipped', + req, + metadata: { reason: 'User was already an admin', completionMethod }, + }); + return { promoted: false, reason: 'already_admin' }; + } + + const rawToken = getBootstrapCookie(req); + if (!rawToken) { + logger.info('Missing token'); + return { promoted: false, reason: 'missing_token' }; + } + + const tokenHash = hashBootstrapToken(rawToken); + const now = new Date(); + + const invite = await BootstrapInvite.findOne({ + where: { tokenHash }, + }); + + if (!invite) { + await logSkip('Invalid token'); + return { promoted: false, reason: 'invalid_token' }; + } + + if (invite.consumedAt) { + await logSkip('Token already used'); + return { promoted: false, reason: 'invite_consumed' }; + } + + if (invite.expiresAt <= now) { + await logSkip('Invitation expired'); + return { promoted: false, reason: 'invite_expired' }; + } + + if (normalizeEmail(invite.email) !== normalizeEmail(user.email)) { + await logSkip('Email mismatch'); + return { promoted: false, reason: 'email_mismatch' }; + } + + return getSequelize().transaction(async (transaction: Transaction) => { + const adminCount = await User.count({ + where: literal(`'admin' = ANY("roles")`), + transaction, + }); + + if (adminCount > 0) { + return { promoted: false, reason: 'admin_exists' } as PromotionResult; + } + + addAdminRole(user); + await user.save({ transaction }); + + const [updated] = await BootstrapInvite.update( + { consumedAt: now }, + { + where: { + id: invite.id, + consumedAt: null, + }, + transaction, + }, + ); + + if (!updated) { + return { promoted: false, reason: 'invite_consumed' } as PromotionResult; + } + + await AuthEventService.log({ + userId: user.id, + type: 'bootstrap_admin_granted', + req, + metadata: { + completionMethod, + inviteId: invite.id, + email: user.email, + }, + }); + + return { promoted: true, reason: 'success' } as PromotionResult; + }); +} diff --git a/src/services/bootstrapService.ts b/src/services/bootstrapService.ts new file mode 100644 index 0000000..1640163 --- /dev/null +++ b/src/services/bootstrapService.ts @@ -0,0 +1,158 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import crypto from 'crypto'; +import { literal, Op } from 'sequelize'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { BootstrapInvite } from '../models/bootstrapInvites.js'; +import { User } from '../models/users.js'; +import getLogger from '../utils/logger.js'; +import { sendBootstrapEmail } from './messagingService.js'; + +const logger = getLogger('adminBootstrapService'); +const DEFAULT_BOOTSTRAP_TTL_MINUTES = 15; + +export class BootstrapError extends Error { + code: string; + status: number; + + constructor(code: string, message: string, status = 400) { + super(message); + this.code = code; + this.status = status; + } +} + +function getBootstrapTtlMinutes(): number { + const raw = Number(process.env.SEAMLESS_BOOTSTRAP_TTL_MINUTES ?? DEFAULT_BOOTSTRAP_TTL_MINUTES); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_BOOTSTRAP_TTL_MINUTES; +} + +export function isBootstrapEnabled(): boolean { + return process.env.SEAMLESS_BOOTSTRAP_ENABLED === 'true'; +} + +export function getBootstrapSecret(): string { + const secret = process.env.SEAMLESS_BOOTSTRAP_SECRET; + if (!secret) { + throw new BootstrapError( + 'BOOTSTRAP_SECRET_MISSING', + 'Bootstrap secret is not configured.', + 500, + ); + } + return secret; +} + +export function assertBootstrapSecret(provided: string | undefined): void { + if (!provided) { + throw new BootstrapError('BOOTSTRAP_UNAUTHORIZED', 'Unauthorized.', 401); + } + + const expected = getBootstrapSecret(); + + const providedBuf = Buffer.from(provided); + const expectedBuf = Buffer.from(expected); + + if ( + providedBuf.length !== expectedBuf.length || + !crypto.timingSafeEqual(providedBuf, expectedBuf) + ) { + throw new BootstrapError('BOOTSTRAP_UNAUTHORIZED', 'Unauthorized.', 401); + } +} + +export async function assertBootstrapAllowed(): Promise { + if (!isBootstrapEnabled()) { + throw new BootstrapError('BOOTSTRAP_DISABLED', 'Bootstrap flow is disabled.', 403); + } + + const adminCount = await User.count({ + where: literal(`'admin' = ANY("roles")`), + }); + + if (adminCount > 0) { + throw new BootstrapError( + 'BOOTSTRAP_ALREADY_COMPLETED', + 'Bootstrap flow is no longer available.', + 410, + ); + } +} + +function generateRawToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +export async function createAdminBootstrapInvite(params: { + email: string; + createdIp?: string | null; + createdUserAgent?: string | null; +}) { + await assertBootstrapAllowed(); + + const existing = await BootstrapInvite.findOne({ + where: { + email: params.email, + consumedAt: null, + expiresAt: { + [Op.gt]: new Date(), + }, + }, + order: [['createdAt', 'DESC']], + }); + + if (existing) { + throw new BootstrapError( + 'BOOTSTRAP_INVITE_ALREADY_EXISTS', + 'An active bootstrap invite already exists for this email.', + 409, + ); + } + + const rawToken = generateRawToken(); + const tokenHash = hashToken(rawToken); + + const ttlMinutes = getBootstrapTtlMinutes(); + const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000); + + await BootstrapInvite.create({ + email: params.email.toLowerCase(), + role: 'admin', + tokenHash, + expiresAt, + consumedAt: null, + createdBy: 'bootstrap', + createdIp: params.createdIp ?? null, + createdUserAgent: params.createdUserAgent ?? null, + lastSentAt: new Date(), + attemptCount: 0, + }); + + const { origins } = await getSystemConfig(); + + const registrationUrl = `${origins[0]}/login?bootstrapToken=${rawToken}`; + if (process.env.NODE_ENV === 'development') { + logger.info('invite link: ', registrationUrl); + } + + sendBootstrapEmail(params.email, registrationUrl); + + return { + registrationUrl, + token: rawToken, + expiresAt, + }; +} + +export function hashBootstrapToken(token: string): string { + return hashToken(token); +} diff --git a/src/services/messagingService.ts b/src/services/messagingService.ts index 1b9c51b..0349352 100644 --- a/src/services/messagingService.ts +++ b/src/services/messagingService.ts @@ -32,3 +32,11 @@ export const sendMagicLinkEmail = async (to: string, token: string, safeRedirect return; } }; + +export const sendBootstrapEmail = async (to: string, url: string) => { + logger.debug(`Sending bootsrap invitation email to: ${to}. URL: ${url}`); + + if (isDevelopment) { + return; + } +}; diff --git a/src/services/sessionIssuance.ts b/src/services/sessionIssuance.ts new file mode 100644 index 0000000..d1db267 --- /dev/null +++ b/src/services/sessionIssuance.ts @@ -0,0 +1,82 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { Request, Response } from 'express'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { clearBootstrapCookie } from '../lib/bootstrapCookie.js'; +import { clearAuthCookies, setAuthCookies } from '../lib/cookie.js'; +import { generateRefreshToken, hashRefreshToken, signAccessToken } from '../lib/token.js'; +import { Session } from '../models/sessions.js'; +import { computeSessionTimes, parseDurationToSeconds } from '../utils/utils.js'; + +type IssueSessionParams = { + user: { + id: string; + email: string; + phone: string | null; + roles: string[]; + }; + req: Request; + res: Response; + authMode: 'web' | 'server'; + clearBootstrap?: boolean; + clearExistingCookies?: boolean; +}; + +export async function issueSessionAndRespond(params: IssueSessionParams): Promise { + const { user, req, res, authMode, clearBootstrap = false, clearExistingCookies = false } = params; + + const refreshToken = generateRefreshToken(); + const refreshTokenHash = await hashRefreshToken(refreshToken); + const { expiresAt, idleExpiresAt } = computeSessionTimes(); + + const session = await Session.create({ + userId: user.id, + infraId: process.env.APP_ID!, + mode: authMode, + refreshTokenHash, + userAgent: req.get('user-agent'), + ipAddress: req.ip, + expiresAt, + idleExpiresAt, + lastUsedAt: undefined, + }); + + const token = await signAccessToken(session.id, user.id, user.roles); + + if (!token || !refreshToken) { + throw new Error('Failed to issue session tokens'); + } + + if (clearExistingCookies) { + clearAuthCookies(res); + } + + if (clearBootstrap) { + clearBootstrapCookie(res); + } + + if (authMode === 'web') { + await setAuthCookies(res, { accessToken: token, refreshToken }); + res.status(200).json({ message: 'Success' }); + return; + } + + const { access_token_ttl, refresh_token_ttl } = await getSystemConfig(); + + res.status(200).json({ + message: 'Success', + token, + refreshToken, + sub: user.id, + roles: user.roles, + email: user.email, + phone: user.phone, + ttl: parseDurationToSeconds(access_token_ttl || '15m'), + refreshTtl: parseDurationToSeconds(refresh_token_ttl || '1h'), + }); +} diff --git a/tests/integration/bootstrap/bootstrap.spec.ts b/tests/integration/bootstrap/bootstrap.spec.ts new file mode 100644 index 0000000..5bcdc13 --- /dev/null +++ b/tests/integration/bootstrap/bootstrap.spec.ts @@ -0,0 +1,139 @@ +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Application } from 'express'; + +import { createApp } from '../../../src/app.js'; +import { + assertBootstrapAllowed, + assertBootstrapSecret, + createAdminBootstrapInvite, + BootstrapError, +} from '../../../src/services/bootstrapService.js'; + +let app: Application; + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +vi.mock('../../../src/services/bootstrapService.js', () => ({ + assertBootstrapAllowed: vi.fn(), + assertBootstrapSecret: vi.fn(), + createAdminBootstrapInvite: vi.fn(), + BootstrapError: class BootstrapError extends Error { + code: string; + status: number; + constructor(code: string, message: string, status: number) { + super(message); + this.code = code; + this.status = status; + } + }, +})); + +it('creates bootstrap invite successfully', async () => { + (createAdminBootstrapInvite as any).mockResolvedValue({ + registrationUrl: + 'http://localhost:3000/register?bootstrapToken=test-secret-that-is-very-long-very-very-very-long', + expiresAt: new Date(), + token: 'test-secret-that-is-very-long-very-very-very-long', + }); + + const res = await request(app) + .post('/internal/bootstrap/admin-invite') + .set('Authorization', 'Bearer test-secret-that-is-very-long-very-very-very-long') + .send({ email: 'test@example.com' }); + + expect(res.status).toBe(201); + + expect(assertBootstrapSecret).toHaveBeenCalledWith( + 'test-secret-that-is-very-long-very-very-very-long', + ); + expect(assertBootstrapAllowed).toHaveBeenCalled(); + + expect(createAdminBootstrapInvite).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@example.com', + }), + ); + + expect(res.body.success).toBe(true); + expect(res.body.data.url).toContain('bootstrapToken'); +}); + +it('fails when missing bearer token', async () => { + (assertBootstrapSecret as any).mockImplementation(() => { + throw new BootstrapError('UNAUTHORIZED', 'Unauthorized', 401); + }); + + const res = await request(app) + .post('/internal/bootstrap/admin-invite') + .send({ email: 'test@example.com' }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); +}); + +it('fails when auth header is not bearer', async () => { + (assertBootstrapSecret as any).mockImplementation(() => { + throw new BootstrapError('UNAUTHORIZED', 'Unauthorized', 401); + }); + + const res = await request(app) + .post('/internal/bootstrap/admin-invite') + .set('Authorization', 'Basic abc123') + .send({ email: 'test@example.com' }); + + expect(res.status).toBe(401); +}); + +it('fails when bootstrap not allowed', async () => { + (assertBootstrapAllowed as any).mockImplementation(() => { + throw new BootstrapError('BOOTSTRAP_DISABLED', 'Not allowed', 403); + }); + + (assertBootstrapSecret as any).mockImplementation(() => vi.fn()); + + const res = await request(app) + .post('/internal/bootstrap/admin-invite') + .set('Authorization', 'Bearer test-secret-that-is-very-long-very-very-very-long') + .send({ email: 'test@example.com' }); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('BOOTSTRAP_DISABLED'); +}); + +it('handles BootstrapError from service', async () => { + (createAdminBootstrapInvite as any).mockImplementation(() => { + throw new BootstrapError('FAILED', 'Something went wrong', 400); + }); + + (assertBootstrapAllowed as any).mockImplementation(() => vi.fn()); + (assertBootstrapSecret as any).mockImplementation(() => vi.fn()); + + const res = await request(app) + .post('/internal/bootstrap/admin-invite') + .set('Authorization', 'Bearer test-secret-that-is-very-long-very-very-very-long') + .send({ email: 'test@example.com' }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('FAILED'); +}); + +it('handles unexpected errors', async () => { + (createAdminBootstrapInvite as any).mockImplementation(() => { + throw new Error('boom'); + }); + + const res = await request(app) + .post('/internal/bootstrap/admin-invite') + .set('Authorization', 'Bearer test-secret') + .send({ email: 'test@example.com' }); + + expect(res.status).toBe(500); + expect(res.body.error.code).toBe('BOOTSTRAP_INTERNAL_ERROR'); +}); diff --git a/tests/integration/otp/otp.spec.ts b/tests/integration/otp/otp.spec.ts index ace017e..48a1a60 100644 --- a/tests/integration/otp/otp.spec.ts +++ b/tests/integration/otp/otp.spec.ts @@ -16,17 +16,11 @@ vi.mock('../../src/utils/otp.js', () => ({ verifyEmailOTP: vi.fn(), })); -vi.mock('../../src/models/sessions.js', () => ({ - Session: { - create: vi.fn(), - }, -})); - -vi.mock('../../src/lib/token.js', () => ({ - signEphemeralToken: vi.fn(), - signAccessToken: vi.fn(), - generateRefreshToken: vi.fn(), - hashRefreshToken: vi.fn(), +vi.mock('../../../src/services/sessionIssuance.js', () => ({ + issueSessionAndRespond: vi.fn(async ({ res }) => { + // simulate real behavior + res.status(200).json({ message: 'Success' }); + }), })); import { @@ -36,8 +30,7 @@ import { verifyEmailOTP, } from '../../../src/utils/otp.js'; -import { signEphemeralToken, signAccessToken } from '../../../src/lib/token.js'; -import { Session } from '../../../src/models/sessions.js'; +import { issueSessionAndRespond } from '../../../src/services/sessionIssuance.js'; let app: Application; @@ -48,10 +41,6 @@ beforeAll(async () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - - (signEphemeralToken as any).mockResolvedValue('ephemeral-token'); - (signAccessToken as any).mockResolvedValue('access-token'); - (Session.create as any).mockResolvedValue({ id: 'session-1' }); }); describe('OTP - Generate', () => { @@ -108,8 +97,6 @@ describe('OTP - Verify Phone', () => { .send({ verificationToken: '123456' }); expect(res.status).toBe(200); - expect(Session.create).toHaveBeenCalled(); - expect(signAccessToken).toHaveBeenCalled(); }); }); @@ -141,7 +128,11 @@ describe('OTP - Verify Email', () => { .post('/otp/verify-email-otp') .send({ verificationToken: '123456' }); - expect(res.status).toBe(200); - expect(Session.create).toHaveBeenCalled(); + expect(issueSessionAndRespond).toHaveBeenCalledTimes(1); + + const call = (issueSessionAndRespond as any).mock.calls[0][0]; + + expect(call.user.id).toBe('user-1'); + expect(call.user.roles).toContain('user'); }); }); diff --git a/tests/integration/registration/register.spec.ts b/tests/integration/registration/register.spec.ts index 357a41f..3d9d1af 100644 --- a/tests/integration/registration/register.spec.ts +++ b/tests/integration/registration/register.spec.ts @@ -27,7 +27,6 @@ vi.mock('../../../src/services/authEventService.js', () => ({ }, })); -// imports after mocks import { User } from '../../../src/models/users.js'; import { signEphemeralToken } from '../../../src/lib/token.js'; import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; diff --git a/tests/unit/middleware/attachAuthMiddleware.spec.ts b/tests/unit/middleware/attachAuthMiddleware.spec.ts index ffa8319..1dbe698 100644 --- a/tests/unit/middleware/attachAuthMiddleware.spec.ts +++ b/tests/unit/middleware/attachAuthMiddleware.spec.ts @@ -6,6 +6,7 @@ vi.unmock('../../../src/middleware/attachAuthMiddleware'); vi.mock('../../../src/middleware/verifyBearerAuth', () => ({ verifyBearerAuth: () => vi.fn().mockResolvedValue('Bearer Auth User'), + verifyCookieAuth: () => vi.fn().mockResolvedValue('Cookie auth'), })); vi.mock('../../../src/middleware/verifyCookieAuth', () => ({ diff --git a/tests/unit/services/bootstrap.spec.ts b/tests/unit/services/bootstrap.spec.ts new file mode 100644 index 0000000..3f4fd03 --- /dev/null +++ b/tests/unit/services/bootstrap.spec.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { + isBootstrapEnabled, + getBootstrapSecret, + assertBootstrapSecret, + assertBootstrapAllowed, + createAdminBootstrapInvite, + BootstrapError, + hashBootstrapToken, +} from '../../../src/services/bootstrapService.js'; + +// ---- mocks ---- + +vi.mock('../../../src/models/bootstrapInvites.js', () => ({ + BootstrapInvite: { + findOne: vi.fn(), + create: vi.fn(), + }, +})); + +vi.mock('../../../src/models/users.js', () => ({ + User: { + count: vi.fn(), + }, +})); + +vi.mock('../../../src/config/getSystemConfig.js', () => ({ + getSystemConfig: vi.fn(), +})); + +vi.mock('../../../src/services/messagingService.js', () => ({ + sendBootstrapEmail: vi.fn(), +})); + +// ---- imports AFTER mocks ---- + +import { BootstrapInvite } from '../../../src/models/bootstrapInvites.js'; +import { User } from '../../../src/models/users.js'; +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { sendBootstrapEmail } from '../../../src/services/messagingService.js'; +import getLogger from '../../../src/utils/logger.js'; + +// ---- setup ---- + +beforeEach(() => { + vi.clearAllMocks(); + + process.env.SEAMLESS_BOOTSTRAP_ENABLED = 'true'; + process.env.SEAMLESS_BOOTSTRAP_SECRET = 'test-secret'; + process.env.SEAMLESS_BOOTSTRAP_TTL_MINUTES = '15'; + process.env.NODE_ENV = 'test'; + + (User.count as any).mockResolvedValue(0); + (BootstrapInvite.findOne as any).mockResolvedValue(null); + (BootstrapInvite.create as any).mockResolvedValue({}); + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:3000'], + }); +}); + +it('returns true when enabled', () => { + process.env.SEAMLESS_BOOTSTRAP_ENABLED = 'true'; + expect(isBootstrapEnabled()).toBe(true); +}); + +it('returns false when disabled', () => { + process.env.SEAMLESS_BOOTSTRAP_ENABLED = 'false'; + expect(isBootstrapEnabled()).toBe(false); +}); + +it('returns secret when set', () => { + process.env.SEAMLESS_BOOTSTRAP_SECRET = 'abc'; + expect(getBootstrapSecret()).toBe('abc'); +}); + +it('throws when secret missing', () => { + delete process.env.SEAMLESS_BOOTSTRAP_SECRET; + + expect(() => getBootstrapSecret()).toThrow(BootstrapError); +}); + +it('passes when token matches', () => { + expect(() => assertBootstrapSecret('test-secret')).not.toThrow(); +}); + +it('fails when missing token', () => { + expect(() => assertBootstrapSecret(undefined)).toThrow(BootstrapError); +}); + +it('fails when token incorrect', () => { + expect(() => assertBootstrapSecret('wrong')).toThrow(BootstrapError); +}); + +it('throws when bootstrap disabled', async () => { + process.env.SEAMLESS_BOOTSTRAP_ENABLED = 'false'; + + await expect(assertBootstrapAllowed()).rejects.toThrow(BootstrapError); +}); + +it('throws when admin exists', async () => { + (User.count as any).mockResolvedValue(1); + + await expect(assertBootstrapAllowed()).rejects.toThrow(BootstrapError); +}); + +it('passes when no admin exists', async () => { + (User.count as any).mockResolvedValue(0); + + await expect(assertBootstrapAllowed()).resolves.toBeUndefined(); +}); + +it('creates bootstrap invite successfully', async () => { + const result = await createAdminBootstrapInvite({ + email: 'test@example.com', + }); + + expect(BootstrapInvite.create).toHaveBeenCalled(); + + expect(sendBootstrapEmail).toHaveBeenCalledWith( + 'test@example.com', + expect.stringContaining('bootstrapToken'), + ); + + expect(result.token).toBeDefined(); + expect(result.registrationUrl).toContain('bootstrapToken'); +}); + +it('throws if active invite exists', async () => { + (BootstrapInvite.findOne as any).mockResolvedValue({ + id: 'existing', + }); + + await expect( + createAdminBootstrapInvite({ + email: 'test@example.com', + }), + ).rejects.toThrow(BootstrapError); +}); + +it('stores email in lowercase', async () => { + await createAdminBootstrapInvite({ + email: 'TEST@EXAMPLE.COM', + }); + + expect(BootstrapInvite.create).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@example.com', + }), + ); +}); + +it('falls back to default TTL when invalid env', async () => { + process.env.SEAMLESS_BOOTSTRAP_TTL_MINUTES = 'invalid'; + + const result = await createAdminBootstrapInvite({ + email: 'test@example.com', + }); + + expect(result.expiresAt).toBeInstanceOf(Date); +}); + +it('hashes token consistently', () => { + const hash1 = hashBootstrapToken('abc'); + const hash2 = hashBootstrapToken('abc'); + + expect(hash1).toBe(hash2); +}); + +it('produces different hashes for different tokens', () => { + const hash1 = hashBootstrapToken('abc'); + const hash2 = hashBootstrapToken('def'); + + expect(hash1).not.toBe(hash2); +}); diff --git a/tests/unit/services/bootstrapPromotionService.spec.ts b/tests/unit/services/bootstrapPromotionService.spec.ts new file mode 100644 index 0000000..b9a8b9a --- /dev/null +++ b/tests/unit/services/bootstrapPromotionService.spec.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { maybePromoteBootstrapAdmin } from '../../../src/services/bootstrapPromotionService.js'; + +// ---- mocks ---- + +vi.mock('../../../src/lib/bootstrapCookie.js', () => ({ + getBootstrapCookie: vi.fn(), +})); + +vi.mock('../../../src/models/bootstrapInvites.js', () => ({ + BootstrapInvite: { + findOne: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('../../../src/models/users.js', () => ({ + User: { + count: vi.fn(), + }, +})); + +vi.mock('../../../src/models/index.js', () => ({ + getSequelize: vi.fn(), +})); + +vi.mock('../../../src/services/authEventService.js', () => ({ + AuthEventService: { + log: vi.fn(), + }, +})); + +// ---- imports AFTER mocks ---- + +import { getBootstrapCookie } from '../../../src/lib/bootstrapCookie.js'; +import { BootstrapInvite } from '../../../src/models/bootstrapInvites.js'; +import { User } from '../../../src/models/users.js'; +import { getSequelize } from '../../../src/models/index.js'; +import { AuthEventService } from '../../../src/services/authEventService.js'; + +// ---- helpers ---- + +const mockReq = {} as any; + +const baseUser = () => + ({ + id: 'user-1', + email: 'test@example.com', + roles: ['user'], + save: vi.fn(), + }) as any; + +const validInvite = () => ({ + id: 'invite-1', + email: 'test@example.com', + expiresAt: new Date(Date.now() + 10000), + consumedAt: null, +}); + +beforeEach(() => { + vi.clearAllMocks(); + + process.env.SEAMLESS_BOOTSTRAP_ENABLED = 'true'; + + (getSequelize as any).mockReturnValue({ + transaction: async (fn: any) => fn({}), + }); + + (User.count as any).mockResolvedValue(0); + (BootstrapInvite.update as any).mockResolvedValue([1]); +}); + +it('returns bootstrap_disabled when feature off', async () => { + process.env.SEAMLESS_BOOTSTRAP_ENABLED = 'false'; + + const result = await maybePromoteBootstrapAdmin({ + user: baseUser(), + req: mockReq, + completionMethod: 'webauthn_registration', + }); + + expect(result).toEqual({ + promoted: false, + reason: 'bootstrap_disabled', + }); +}); + +it('skips if user already admin', async () => { + const user = baseUser(); + user.roles = ['admin']; + + const result = await maybePromoteBootstrapAdmin({ + user, + req: mockReq, + completionMethod: 'webauthn_registration', + }); + + expect(result).toEqual({ + promoted: false, + reason: 'already_admin', + }); +}); + +it('returns missing_token when no cookie', async () => { + (getBootstrapCookie as any).mockReturnValue(null); + + const result = await maybePromoteBootstrapAdmin({ + user: baseUser(), + req: mockReq, + completionMethod: 'webauthn_registration', + }); + + expect(result).toEqual({ + promoted: false, + reason: 'missing_token', + }); +}); + +it('returns invalid_token when invite not found', async () => { + (getBootstrapCookie as any).mockReturnValue('token'); + (BootstrapInvite.findOne as any).mockResolvedValue(null); + + const result = await maybePromoteBootstrapAdmin({ + user: baseUser(), + req: mockReq, + completionMethod: 'webauthn_registration', + }); + + expect(result.reason).toBe('invalid_token'); +}); + +it('returns invite_consumed when already used', async () => { + (getBootstrapCookie as any).mockReturnValue('token'); + (BootstrapInvite.findOne as any).mockResolvedValue({ + ...validInvite(), + consumedAt: new Date(), + }); + + const result = await maybePromoteBootstrapAdmin({ + user: baseUser(), + req: mockReq, + completionMethod: 'webauthn_registration', + }); + + expect(result.reason).toBe('invite_consumed'); +}); + +it('returns invite_expired when expired', async () => { + (getBootstrapCookie as any).mockReturnValue('token'); + (BootstrapInvite.findOne as any).mockResolvedValue({ + ...validInvite(), + expiresAt: new Date(Date.now() - 1000), + }); + + const result = await maybePromoteBootstrapAdmin({ + user: baseUser(), + req: mockReq, + completionMethod: 'webauthn_registration', + }); + + expect(result.reason).toBe('invite_expired'); +}); + +it('returns email_mismatch when emails differ', async () => { + (getBootstrapCookie as any).mockReturnValue('token'); + (BootstrapInvite.findOne as any).mockResolvedValue({ + ...validInvite(), + email: 'other@example.com', + }); + + const result = await maybePromoteBootstrapAdmin({ + user: baseUser(), + req: mockReq, + completionMethod: 'webauthn_registration', + }); + + expect(result.reason).toBe('email_mismatch'); +}); + +it('returns admin_exists when admin already present', async () => { + (getBootstrapCookie as any).mockReturnValue('token'); + (BootstrapInvite.findOne as any).mockResolvedValue(validInvite()); + + (User.count as any).mockResolvedValue(1); + + const result = await maybePromoteBootstrapAdmin({ + user: baseUser(), + req: mockReq, + completionMethod: 'webauthn_registration', + }); + + expect(result.reason).toBe('admin_exists'); +}); + +it('returns invite_consumed if update fails (race condition)', async () => { + (getBootstrapCookie as any).mockReturnValue('token'); + (BootstrapInvite.findOne as any).mockResolvedValue(validInvite()); + + (BootstrapInvite.update as any).mockResolvedValue([0]); + + const user = baseUser(); + + const result = await maybePromoteBootstrapAdmin({ + user, + req: mockReq, + completionMethod: 'webauthn_registration', + }); + + expect(result.reason).toBe('invite_consumed'); +}); + +it('promotes user to admin successfully', async () => { + (getBootstrapCookie as any).mockReturnValue('token'); + (BootstrapInvite.findOne as any).mockResolvedValue(validInvite()); + + const user = baseUser(); + + const result = await maybePromoteBootstrapAdmin({ + user, + req: mockReq, + completionMethod: 'webauthn_registration', + }); + + expect(result).toEqual({ + promoted: true, + reason: 'success', + }); + + expect(user.roles).toContain('admin'); + expect(user.save).toHaveBeenCalled(); + + expect(BootstrapInvite.update).toHaveBeenCalled(); + + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'bootstrap_admin_granted', + }), + ); +}); diff --git a/tests/unit/services/sessionIssueService.spec.ts b/tests/unit/services/sessionIssueService.spec.ts new file mode 100644 index 0000000..2ef7b68 --- /dev/null +++ b/tests/unit/services/sessionIssueService.spec.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { issueSessionAndRespond } from '../../../src/services/sessionIssuance.js'; + +vi.mock('../../../src/lib/token.js', () => ({ + generateRefreshToken: vi.fn(), + hashRefreshToken: vi.fn(), + signAccessToken: vi.fn(), +})); + +vi.mock('../../../src/models/sessions.js', () => ({ + Session: { + create: vi.fn(), + }, +})); + +vi.mock('../../../src/lib/cookie.js', () => ({ + setAuthCookies: vi.fn(), + clearAuthCookies: vi.fn(), +})); + +vi.mock('../../../src/lib/bootstrapCookie.js', () => ({ + clearBootstrapCookie: vi.fn(), +})); + +vi.mock('../../../src/config/getSystemConfig.js', () => ({ + getSystemConfig: vi.fn(), +})); + +vi.mock('../../../src/utils/utils.js', () => ({ + computeSessionTimes: vi.fn(), + parseDurationToSeconds: vi.fn(), +})); + +// ---- Imports AFTER mocks ---- + +import { generateRefreshToken, hashRefreshToken, signAccessToken } from '../../../src/lib/token.js'; + +import { Session } from '../../../src/models/sessions.js'; +import { setAuthCookies, clearAuthCookies } from '../../../src/lib/cookie.js'; +import { clearBootstrapCookie } from '../../../src/lib/bootstrapCookie.js'; +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { computeSessionTimes, parseDurationToSeconds } from '../../../src/utils/utils.js'; + +// ---- Helpers ---- + +const mockReq = () => + ({ + get: vi.fn().mockReturnValue('test-agent'), + ip: '127.0.0.1', + }) as any; + +const mockRes = () => { + const res: any = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +}; + +const mockUser = { + id: 'user-1', + email: 'test@example.com', + phone: '+1234567890', + roles: ['user'], +}; + +// ---- Setup ---- + +beforeEach(() => { + vi.clearAllMocks(); + + (generateRefreshToken as any).mockReturnValue('refresh-token'); + (hashRefreshToken as any).mockResolvedValue('hashed-refresh'); + (signAccessToken as any).mockResolvedValue('access-token'); + + (Session.create as any).mockResolvedValue({ id: 'session-1' }); + + (computeSessionTimes as any).mockReturnValue({ + expiresAt: new Date(), + idleExpiresAt: new Date(), + }); + + (getSystemConfig as any).mockResolvedValue({ + access_token_ttl: '15m', + refresh_token_ttl: '1h', + }); + + (parseDurationToSeconds as any).mockImplementation((v: string) => (v === '15m' ? 900 : 3600)); +}); + +it('issues session in web mode and sets cookies', async () => { + const req = mockReq(); + const res = mockRes(); + + await issueSessionAndRespond({ + user: mockUser, + req, + res, + authMode: 'web', + }); + + expect(Session.create).toHaveBeenCalled(); + expect(signAccessToken).toHaveBeenCalled(); + + expect(setAuthCookies).toHaveBeenCalledWith(res, { + accessToken: 'access-token', + refreshToken: 'refresh-token', + }); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ message: 'Success' }); +}); + +it('issues session in server mode and returns JSON payload', async () => { + const req = mockReq(); + const res = mockRes(); + + await issueSessionAndRespond({ + user: mockUser, + req, + res, + authMode: 'server', + }); + + expect(res.status).toHaveBeenCalledWith(200); + + expect(res.json).toHaveBeenCalledWith({ + message: 'Success', + token: 'access-token', + refreshToken: 'refresh-token', + sub: mockUser.id, + roles: mockUser.roles, + email: mockUser.email, + phone: mockUser.phone, + ttl: 900, + refreshTtl: 3600, + }); +}); + +it('clears existing auth cookies when flag set', async () => { + const req = mockReq(); + const res = mockRes(); + + await issueSessionAndRespond({ + user: mockUser, + req, + res, + authMode: 'web', + clearExistingCookies: true, + }); + + expect(clearAuthCookies).toHaveBeenCalledWith(res); +}); + +it('clears bootstrap cookie when flag set', async () => { + const req = mockReq(); + const res = mockRes(); + + await issueSessionAndRespond({ + user: mockUser, + req, + res, + authMode: 'web', + clearBootstrap: true, + }); + + expect(clearBootstrapCookie).toHaveBeenCalledWith(res); +}); + +it('throws if token generation fails', async () => { + const req = mockReq(); + const res = mockRes(); + + (signAccessToken as any).mockResolvedValue(null); + + await expect( + issueSessionAndRespond({ + user: mockUser, + req, + res, + authMode: 'web', + }), + ).rejects.toThrow('Failed to issue session tokens'); +}); + +it('passes request metadata into session creation', async () => { + const req = mockReq(); + const res = mockRes(); + + await issueSessionAndRespond({ + user: mockUser, + req, + res, + authMode: 'web', + }); + + expect(Session.create).toHaveBeenCalledWith( + expect.objectContaining({ + userAgent: 'test-agent', + ipAddress: '127.0.0.1', + }), + ); +}); + +it('uses default TTL values when config missing', async () => { + const req = mockReq(); + const res = mockRes(); + + (getSystemConfig as any).mockResolvedValue({}); + + await issueSessionAndRespond({ + user: mockUser, + req, + res, + authMode: 'server', + }); + + expect(parseDurationToSeconds).toHaveBeenCalledWith('15m'); + expect(parseDurationToSeconds).toHaveBeenCalledWith('1h'); +}); From 7e8385743c178dc5f2b2bb65ee64c6a02dbeae7c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 31 Mar 2026 15:37:38 -0400 Subject: [PATCH 2/3] fix: fix last login --- src/controllers/magicLinks.ts | 6 +++++- src/controllers/otp.ts | 12 ++++++++++++ src/controllers/webauthn.ts | 8 ++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index 0488174..f125c78 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -223,7 +223,11 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) { clearBootstrap: true, }); - return res.json({ message: 'Success' }); + user.update({ + lastLogin: new Date(), + }); + + return; } return res.status(204).json({ message: 'Success' }); } diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index 4b5f376..2e4727f 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -313,6 +313,10 @@ export const verifyEmail = async (req: Request, res: Response) => { authMode: AUTH_MODE, }); + user.update({ + lastLogin: new Date(), + }); + return; } return res.json({ message: 'Success' }); @@ -394,6 +398,10 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { authMode: AUTH_MODE, }); + user.update({ + lastLogin: new Date(), + }); + return; } return res.json({ message: 'Success' }); @@ -496,6 +504,10 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { authMode: AUTH_MODE, }); + user.update({ + lastLogin: new Date(), + }); + return; } return res.json({ message: 'Success' }); diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index dfdad54..66b551e 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -259,6 +259,10 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { clearBootstrap: true, }); + user.update({ + lastLogin: new Date(), + }); + return; } catch (err) { logger.error(`Error in verifyWebAuthnRegistration: ${err}`); @@ -471,6 +475,10 @@ const verifyWebAuthn = async (req: Request, res: Response) => { clearExistingCookies: true, }); + user.update({ + lastLogin: new Date(), + }); + return; } } catch (error) { From b5928d229f0c17c36c1b6055f4ff5e9477cbad03 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 3 Apr 2026 11:00:19 -0400 Subject: [PATCH 3/3] chore: logging, remove bootstrap TTL and updated .env examples --- .env.example | 9 +++------ src/app.ts | 15 ++------------- src/controllers/bootstrap.ts | 5 +++++ src/lib/bootstrapCookie.ts | 10 +++++++++- src/middleware/verifyBearerAuth.ts | 2 +- src/services/bootstrapPromotionService.ts | 21 ++++++--------------- src/services/bootstrapService.ts | 12 +++--------- tests/setup/mocks.ts | 2 ++ tests/unit/services/bootstrap.spec.ts | 11 ----------- 9 files changed, 31 insertions(+), 56 deletions(-) diff --git a/.env.example b/.env.example index 5d94a0b..f674054 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ NODE_ENV=development VERSION=1.0.0 APP_NAME=Seamless Auth Example APP_ID=local-dev -APP_ORIGIN=http://localhost:5001 +APP_ORIGIN=http://localhost:3000 ISSUER=http://localhost:5312 # "web" for website to auth server, "server" for api server to auth server auth @@ -40,14 +40,11 @@ JWKS_ACTIVE_KID=dev-main # WEBAUTHN RPID=localhost -ORIGINS=http://localhost:5001 +ORIGINS=http://localhost:5173,http://localhost:5174 # ADMIN BOOTSTRAP # Enables bootstrap feature SEAMLESS_BOOTSTRAP_ENABLED=true # Secret used to authorize bootstrap invite creation -SEAMLESS_BOOTSTRAP_SECRET=dev-bootstrap-secret-123 - -# How long the invite (and cookie) is valid -SEAMLESS_BOOTSTRAP_TTL_MINUTES=15 \ No newline at end of file +SEAMLESS_BOOTSTRAP_SECRET=dev-bootstrap-secret-123 \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 2c4b7e8..a56d0a7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -21,18 +21,7 @@ import getLogger from './utils/logger.js'; const logger = getLogger('app'); const app = express(); -const isValidUrl = (str: string) => { - try { - if (str === '*') return true; - new URL(str); - return true; - } catch { - throw new Error('Invalid host provied.'); - } -}; - -const rawOrigin = process.env.APP_ORIGIN?.trim(); -const allowedOrigin = rawOrigin && isValidUrl(rawOrigin) ? rawOrigin : ''; +const rawOrigin = process.env.APP_ORIGINS!.split(','); const corsOptions: CorsOptions = { origin: (origin, callback) => { @@ -40,7 +29,7 @@ const corsOptions: CorsOptions = { return callback(null, true); } - if (origin === allowedOrigin || origin === 'http://localhost:5174') { + if (rawOrigin.includes(origin)) { return callback(null, true); } diff --git a/src/controllers/bootstrap.ts b/src/controllers/bootstrap.ts index e7d07b7..3cd34a0 100644 --- a/src/controllers/bootstrap.ts +++ b/src/controllers/bootstrap.ts @@ -12,6 +12,9 @@ import { BootstrapError, createAdminBootstrapInvite, } from '../services/bootstrapService.js'; +import getLogger from '../utils/logger.js'; + +const logger = getLogger('bootstrapAdminInvite'); function getBearerToken(req: Request): string | undefined { const auth = req.header('authorization'); @@ -25,6 +28,8 @@ function getBearerToken(req: Request): string | undefined { export async function createAdminBootstrapInviteHandler(req: Request, res: Response) { try { + logger.info('Creating a bootstrap admin invitation'); + const bearerToken = getBearerToken(req); assertBootstrapSecret(bearerToken); diff --git a/src/lib/bootstrapCookie.ts b/src/lib/bootstrapCookie.ts index c34b79b..4c8411b 100644 --- a/src/lib/bootstrapCookie.ts +++ b/src/lib/bootstrapCookie.ts @@ -6,23 +6,31 @@ import { Request, Response } from 'express'; +import getLogger from '../utils/logger.js'; + const COOKIE_NAME = 'seamless_bootstrap_token'; +const logger = getLogger('bootstrapCookie'); + export function setBootstrapCookie(res: Response, token: string) { res.cookie(COOKIE_NAME, token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', - maxAge: 15 * 60 * 1000, // 15 minutes + maxAge: 15 * 60 * 1000, path: '/', }); } export function getBootstrapCookie(req: Request): string | null { + logger.debug( + `Checking for bootstrap cookie. Cookie value: ${req.cookies?.[COOKIE_NAME] ?? null}`, + ); return req.cookies?.[COOKIE_NAME] ?? null; } export function clearBootstrapCookie(res: Response) { + logger.debug(`Clearing bootstrap cookie.`); res.clearCookie(COOKIE_NAME, { httpOnly: true, secure: process.env.NODE_ENV === 'production', diff --git a/src/middleware/verifyBearerAuth.ts b/src/middleware/verifyBearerAuth.ts index a57d69e..6b7c2a0 100644 --- a/src/middleware/verifyBearerAuth.ts +++ b/src/middleware/verifyBearerAuth.ts @@ -15,7 +15,7 @@ const logger = getLogger('verifyBearerAuth'); export async function verifyBearerAuth(req: Request, res: Response, next: NextFunction) { const auth = req.headers.authorization; if (!auth?.startsWith('Bearer ')) { - logger.error('Missing beartoken for authentication request'); + logger.error('Missing bearer token for authentication request'); return res.status(401).json({ error: 'missing bearer token' }); } diff --git a/src/services/bootstrapPromotionService.ts b/src/services/bootstrapPromotionService.ts index 516e14f..f043f78 100644 --- a/src/services/bootstrapPromotionService.ts +++ b/src/services/bootstrapPromotionService.ts @@ -66,6 +66,7 @@ export async function maybePromoteBootstrapAdmin(params: { logger.debug('checking for promotion'); async function logSkip(reason: string) { + logger.info(`Skipped bootstrap for ${reason}`); await AuthEventService.log({ userId: user.id, type: 'bootstrap_admin_check_skipped', @@ -75,30 +76,18 @@ export async function maybePromoteBootstrapAdmin(params: { } if (!isBootstrapEnabled()) { - logger.info('Bootstrap is not enabled'); - await AuthEventService.log({ - userId: user.id, - type: 'bootstrap_admin_check_skipped', - req, - metadata: { reason: 'bootstrap disabled', completionMethod }, - }); + logSkip('disabled'); return { promoted: false, reason: 'bootstrap_disabled' }; } if (userHasAdminRole(user)) { - logger.info('User is already and admin'); - await AuthEventService.log({ - userId: user.id, - type: 'bootstrap_admin_check_skipped', - req, - metadata: { reason: 'User was already an admin', completionMethod }, - }); + logSkip('bootstrap_admin_check_skipped'); return { promoted: false, reason: 'already_admin' }; } const rawToken = getBootstrapCookie(req); if (!rawToken) { - logger.info('Missing token'); + logSkip('Missing token'); return { promoted: false, reason: 'missing_token' }; } @@ -168,6 +157,8 @@ export async function maybePromoteBootstrapAdmin(params: { }, }); + logger.info('User promoted to admin'); + return { promoted: true, reason: 'success' } as PromotionResult; }); } diff --git a/src/services/bootstrapService.ts b/src/services/bootstrapService.ts index 1640163..a92bdf0 100644 --- a/src/services/bootstrapService.ts +++ b/src/services/bootstrapService.ts @@ -14,7 +14,6 @@ import getLogger from '../utils/logger.js'; import { sendBootstrapEmail } from './messagingService.js'; const logger = getLogger('adminBootstrapService'); -const DEFAULT_BOOTSTRAP_TTL_MINUTES = 15; export class BootstrapError extends Error { code: string; @@ -27,11 +26,6 @@ export class BootstrapError extends Error { } } -function getBootstrapTtlMinutes(): number { - const raw = Number(process.env.SEAMLESS_BOOTSTRAP_TTL_MINUTES ?? DEFAULT_BOOTSTRAP_TTL_MINUTES); - return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_BOOTSTRAP_TTL_MINUTES; -} - export function isBootstrapEnabled(): boolean { return process.env.SEAMLESS_BOOTSTRAP_ENABLED === 'true'; } @@ -50,6 +44,7 @@ export function getBootstrapSecret(): string { export function assertBootstrapSecret(provided: string | undefined): void { if (!provided) { + logger.error('Nothing provided for bootstrap secret'); throw new BootstrapError('BOOTSTRAP_UNAUTHORIZED', 'Unauthorized.', 401); } @@ -62,6 +57,7 @@ export function assertBootstrapSecret(provided: string | undefined): void { providedBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(providedBuf, expectedBuf) ) { + logger.error('Incorrect bootstrap secret'); throw new BootstrapError('BOOTSTRAP_UNAUTHORIZED', 'Unauthorized.', 401); } } @@ -120,9 +116,7 @@ export async function createAdminBootstrapInvite(params: { const rawToken = generateRawToken(); const tokenHash = hashToken(rawToken); - - const ttlMinutes = getBootstrapTtlMinutes(); - const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); await BootstrapInvite.create({ email: params.email.toLowerCase(), diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index c868c14..f0838d7 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -1,5 +1,7 @@ import { vi } from 'vitest'; +vi.stubEnv('APP_ORIGINS', 'http://localhost:5137'); + export let mockUser: any = { id: 'user-1', email: 'test@example.com', diff --git a/tests/unit/services/bootstrap.spec.ts b/tests/unit/services/bootstrap.spec.ts index 3f4fd03..b17bf69 100644 --- a/tests/unit/services/bootstrap.spec.ts +++ b/tests/unit/services/bootstrap.spec.ts @@ -48,7 +48,6 @@ beforeEach(() => { process.env.SEAMLESS_BOOTSTRAP_ENABLED = 'true'; process.env.SEAMLESS_BOOTSTRAP_SECRET = 'test-secret'; - process.env.SEAMLESS_BOOTSTRAP_TTL_MINUTES = '15'; process.env.NODE_ENV = 'test'; (User.count as any).mockResolvedValue(0); @@ -150,16 +149,6 @@ it('stores email in lowercase', async () => { ); }); -it('falls back to default TTL when invalid env', async () => { - process.env.SEAMLESS_BOOTSTRAP_TTL_MINUTES = 'invalid'; - - const result = await createAdminBootstrapInvite({ - email: 'test@example.com', - }); - - expect(result.expiresAt).toBeInstanceOf(Date); -}); - it('hashes token consistently', () => { const hash1 = hashBootstrapToken('abc'); const hash2 = hashBootstrapToken('abc');