From adfc28cdbc1fe91094532ca4688a8104ffed6a19 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 26 May 2026 23:15:06 -0400 Subject: [PATCH] feat: redact sensitive information as a service --- README.md | 13 +++ docs/architecture.md | 3 + src/controllers/admin.ts | 10 ++- src/controllers/bootstrap.ts | 21 +++-- src/controllers/internalSecurity.ts | 3 +- src/controllers/magicLinks.ts | 20 ++--- src/controllers/oauth.ts | 8 +- src/controllers/otp.ts | 50 +++-------- src/controllers/registration.ts | 8 +- src/controllers/user.ts | 4 +- src/controllers/webauthn.ts | 7 +- src/lib/externalDelivery.ts | 59 +++++++++++++ src/middleware/verifyBearerAuth.ts | 2 +- src/models/authEvents.ts | 12 +++ src/schemas/bootstrap.schema.ts | 4 +- src/services/authEventSerialization.ts | 29 ++++++ src/services/authEventService.ts | 3 +- src/services/bootstrapService.ts | 5 +- src/services/messagingService.ts | 8 +- src/utils/logger.ts | 8 +- src/utils/redaction.ts | 80 +++++++++++++++++ tests/integration/bootstrap/bootstrap.spec.ts | 22 +++++ tests/unit/lib/externalDelivery.spec.ts | 88 +++++++++++++++++++ .../services/authEventSerialization.spec.ts | 39 ++++++++ tests/unit/services/authEventService.spec.ts | 49 +++++++++++ tests/unit/utils/redaction.spec.ts | 52 +++++++++++ 26 files changed, 519 insertions(+), 88 deletions(-) create mode 100644 src/lib/externalDelivery.ts create mode 100644 src/services/authEventSerialization.ts create mode 100644 src/utils/redaction.ts create mode 100644 tests/unit/lib/externalDelivery.spec.ts create mode 100644 tests/unit/services/authEventSerialization.spec.ts create mode 100644 tests/unit/utils/redaction.spec.ts diff --git a/README.md b/README.md index e4b60ed..f9a246c 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,19 @@ Security notes: - Existing users are linked by verified email; new users are created only when `allowSignup` is enabled for that provider. +### Sensitive Data Redaction + +SeamlessAuth redacts sensitive data from logs and auth-event metadata by default. This includes +tokens, OTPs, magic-link URLs, PRF salts and outputs, OAuth codes/state, bearer credentials, +configured secrets, email/phone fields inside audit snapshots, and legacy event metadata returned +through admin endpoints. + +Delivery payloads that contain OTPs or magic-link/bootstrap tokens are returned only when callers +explicitly request external delivery with `x-seamless-auth-delivery-mode: external`. In production, +external delivery also requires a valid `x-seamless-service-token` from a trusted server adapter. +Development-only bootstrap token details require `x-seamless-auth-include-sensitive: true` and are +never enabled in production. + ### Scoped Roles Global roles may be plain names such as `admin` or scoped names such as `admin:read` and diff --git a/docs/architecture.md b/docs/architecture.md index fe6866a..1c88776 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -190,6 +190,9 @@ Supported direct transports: - Twilio SMS Flows can opt out of direct sending and instead return delivery payloads by sending `x-seamless-auth-delivery-mode: external`. +Because delivery payloads contain OTPs or one-time links, production external delivery also requires +a valid `x-seamless-service-token` from a trusted server adapter. Non-production development and test +flows may request external delivery explicitly without a service token. This split is important when writing tests or integrating with an upstream orchestration service. diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts index ff90687..97b0366 100644 --- a/src/controllers/admin.ts +++ b/src/controllers/admin.ts @@ -14,10 +14,12 @@ import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; import { CreateUserSchema, UpdateUserSchema } from '../schemas/admin.requests.js'; import { AuthEventQuerySchema } from '../schemas/internal.query.js'; +import { serializeAuthEvents } from '../services/authEventSerialization.js'; import { AuthEventService } from '../services/authEventService.js'; import { hardRevokeSession } from '../services/sessionService.js'; import { ServiceRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; +import { redactMetadata } from '../utils/redaction.js'; const logger = getLogger('admin'); @@ -138,7 +140,7 @@ export const updateUser = async (req: ServiceRequest, res: Response) => { const parsed = UpdateUserSchema.safeParse(req.body); if (!parsed.success || Object.keys(parsed.data).length === 0) { - logger.error(`Failed to parse update user body. ${JSON.stringify(req.body)}`); + logger.error(`Failed to parse update user body. ${JSON.stringify(redactMetadata(req.body))}`); return res.status(400).json({ error: 'Invalid update payload', details: parsed.error, @@ -216,7 +218,7 @@ export const getUserDetail = async (req: ServiceRequest, res: Response) => { user, sessions, credentials, - events, + events: serializeAuthEvents(events), }); }; @@ -249,7 +251,7 @@ export const getUserAnomalies = async (req: Request, res: Response) => { }); return res.json({ - suspiciousEvents: suspiciousEvents, + suspiciousEvents: serializeAuthEvents(suspiciousEvents), relatedIps: Array.from(ips), relatedAgents: Array.from(agents), }); @@ -431,7 +433,7 @@ export const getAuthEvents = async (req: ServiceRequest, res: Response) => { }), ]); - return res.json({ events, total }); + return res.json({ events: serializeAuthEvents(events), total }); } catch (err) { logger.error(`Failed to fetch auth events: ${err}`); res.status(500).json({ error: 'Failed to fetch events' }); diff --git a/src/controllers/bootstrap.ts b/src/controllers/bootstrap.ts index 031a09f..1a04d48 100644 --- a/src/controllers/bootstrap.ts +++ b/src/controllers/bootstrap.ts @@ -6,6 +6,10 @@ import { Request, Response } from 'express'; +import { + canReturnExternalDelivery, + canReturnSensitiveDevelopmentDetails, +} from '../lib/externalDelivery.js'; import { assertBootstrapAllowed, assertBootstrapSecret, @@ -15,7 +19,6 @@ import { import getLogger from '../utils/logger.js'; const logger = getLogger('bootstrapAdminInvite'); -const EXTERNAL_DELIVERY_HEADER = 'x-seamless-auth-delivery-mode'; function getBearerToken(req: Request): string | undefined { const auth = req.header('authorization'); @@ -27,10 +30,6 @@ function getBearerToken(req: Request): string | undefined { return token; } -function wantsExternalDelivery(req: Request) { - return req.get(EXTERNAL_DELIVERY_HEADER)?.toLowerCase() === 'external'; -} - export async function createAdminBootstrapInviteHandler(req: Request, res: Response) { try { logger.info('Creating a bootstrap admin invitation'); @@ -41,7 +40,9 @@ export async function createAdminBootstrapInviteHandler(req: Request, res: Respo await assertBootstrapAllowed(); const { email } = req.body; - const useExternalDelivery = wantsExternalDelivery(req); + const useExternalDelivery = await canReturnExternalDelivery(req); + const includeSensitiveDetails = + useExternalDelivery || canReturnSensitiveDevelopmentDetails(req); const result = await createAdminBootstrapInvite({ email, @@ -53,9 +54,13 @@ export async function createAdminBootstrapInviteHandler(req: Request, res: Respo return res.status(201).json({ success: true, data: { - url: result.registrationUrl, expiresAt: result.expiresAt.toISOString(), - token: result.token, + ...(includeSensitiveDetails + ? { + url: result.registrationUrl, + token: result.token, + } + : {}), ...(useExternalDelivery ? { delivery: { diff --git a/src/controllers/internalSecurity.ts b/src/controllers/internalSecurity.ts index 8e3a2b6..4549e9b 100644 --- a/src/controllers/internalSecurity.ts +++ b/src/controllers/internalSecurity.ts @@ -8,6 +8,7 @@ import { Request, Response } from 'express'; import { Op } from 'sequelize'; import { AuthEvent } from '../models/authEvents.js'; +import { serializeAuthEvents } from '../services/authEventSerialization.js'; import getLogger from '../utils/logger.js'; const logger = getLogger('internalSecurity'); @@ -55,7 +56,7 @@ export const getSecurityAnomalies = async (_req: Request, res: Response) => { }); return res.json({ - suspiciousEvents: events, + suspiciousEvents: serializeAuthEvents(events), total: events.length, }); } catch { diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index d9f093b..84c900b 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -9,6 +9,7 @@ import { Request, Response } from 'express'; import { Op } from 'sequelize'; import { getSystemConfig } from '../config/getSystemConfig.js'; +import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { AuthEvent } from '../models/authEvents.js'; import { MagicLinkToken } from '../models/magicLinks.js'; import { User } from '../models/users.js'; @@ -25,11 +26,6 @@ const logger = getLogger('magic-links'); const TTL_MINUTES = 15; const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; -const EXTERNAL_DELIVERY_HEADER = 'x-seamless-auth-delivery-mode'; - -function wantsExternalDelivery(req: Request) { - return req.get(EXTERNAL_DELIVERY_HEADER)?.toLowerCase() === 'external'; -} async function rejectDisabledMagicLink(req: Request, res: Response, userId?: string | null) { const policy = await getLoginPolicy(); @@ -52,7 +48,7 @@ async function rejectDisabledMagicLink(req: Request, res: Response, userId?: str export async function requestMagicLink(req: Request, res: Response) { const authReq = req as AuthenticatedRequest; const preAuthUser = authReq.user; - const useExternalDelivery = wantsExternalDelivery(req); + const useExternalDelivery = await canReturnExternalDelivery(req); if (await rejectDisabledMagicLink(req, res, preAuthUser?.id)) { return; @@ -140,22 +136,22 @@ export async function verifyMagicLink(req: Request, res: Response) { }); if (!record) { - logger.warn(`No magic link found for token: ${token}`); + logger.warn('No magic link found for supplied token'); return res.status(400).json({ error: 'Invalid verification token' }); } if (record.used_at) { - logger.warn(`Magic link token is already used ${token}`); + logger.warn('Magic link token is already used'); return res.status(400).json({ error: 'Invalid verification token' }); } if (record.expires_at < new Date()) { - logger.warn(`Magic link token expired: ${token}`); + logger.warn('Magic link token expired'); return res.status(400).json({ error: 'Invalid verification token' }); } // Atomic consume - logger.info(`Magic link being consumed ${token}`); + logger.info('Magic link being consumed'); const [updated] = await MagicLinkToken.update( { used_at: new Date() }, @@ -168,7 +164,7 @@ export async function verifyMagicLink(req: Request, res: Response) { ); if (!updated) { - logger.error(`Magic link token was not consumted: ${token}`); + logger.error('Magic link token was not consumed'); return res.status(500).json({ error: 'Failed to use token' }); } @@ -176,7 +172,7 @@ export async function verifyMagicLink(req: Request, res: Response) { userId: record.user_id, type: 'magic_link_success', req, - metadata: { message: `Token: ${token}` }, + metadata: { reason: 'Magic link token consumed' }, }); // Device binding check diff --git a/src/controllers/oauth.ts b/src/controllers/oauth.ts index 5e0b1e0..72a5de1 100644 --- a/src/controllers/oauth.ts +++ b/src/controllers/oauth.ts @@ -70,7 +70,7 @@ export async function startOAuthLogin(req: Request, res: Response) { state, }), }); - } catch (error) { + } catch { await AuthEventService.log({ type: 'oauth_login_failed', req, @@ -78,7 +78,7 @@ export async function startOAuthLogin(req: Request, res: Response) { }); return res.status(400).json({ - error: error instanceof Error ? error.message : 'OAuth start failed', + error: 'OAuth start failed', }); } } @@ -140,7 +140,7 @@ export async function finishOAuthLogin(req: Request, res: Response) { authMode: AUTH_MODE, clearExistingCookies: true, }); - } catch (error) { + } catch { await AuthEventService.log({ type: 'oauth_login_failed', req, @@ -148,7 +148,7 @@ export async function finishOAuthLogin(req: Request, res: Response) { }); return res.status(400).json({ - error: error instanceof Error ? error.message : 'OAuth login failed', + error: 'OAuth login failed', }); } } diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index 9ab3dc0..a1f8864 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -7,6 +7,7 @@ import { Request, Response } from 'express'; import { setAuthCookies } from '../lib/cookie.js'; +import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { signEphemeralToken } from '../lib/token.js'; import { AuthEventService } from '../services/authEventService.js'; import { @@ -27,11 +28,6 @@ import { isValidEmail, isValidPhoneNumber, normalizePhoneNumber } from '../utils const logger = getLogger('otp'); const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; -const EXTERNAL_DELIVERY_HEADER = 'x-seamless-auth-delivery-mode'; - -function wantsExternalDelivery(req: Request) { - return req.get(EXTERNAL_DELIVERY_HEADER)?.toLowerCase() === 'external'; -} async function rejectDisabledLoginMethod( method: LoginMethod, @@ -62,7 +58,7 @@ export const sendPhoneOTP = async (req: Request, res: Response) => { const user = authReq.user; const phone = user.phone; const normalizedPhone = normalizePhoneNumber(phone); - const useExternalDelivery = wantsExternalDelivery(req); + const useExternalDelivery = await canReturnExternalDelivery(req); if (!phone) { logger.warn(`Missing phone`); @@ -157,7 +153,7 @@ export const sendEmailOTP = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; const user = authReq.user; const email = user.email; - const useExternalDelivery = wantsExternalDelivery(req); + const useExternalDelivery = await canReturnExternalDelivery(req); try { if (!user) { @@ -274,7 +270,7 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { logger.info(`Verifying phone number: ${phone}`); if (!user || !user.phoneVerificationTokenExpiry || !user.phoneVerificationToken) { - logger.warn(`Failed to find a user for this phone verification token - ${verificationToken}`); + logger.warn('Failed to find a user for this phone verification token'); await AuthEventService.log({ userId: user.id, type: 'verify_otp_suspicious', @@ -323,11 +319,7 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { } res.json({ message: 'Success' }); } else { - logger.warn( - `Verfication tokens did not match ${verificationToken} vs ${ - user.phoneVerificationToken - } or ${user.phoneVerificationTokenExpiry} is less than ${new Date().getTime()}`, - ); + logger.warn(`Verification tokens did not match or expired for phone verification`); return res.status(401).json({ error: 'Not allowed' }); } } catch (error) { @@ -346,9 +338,7 @@ export const verifyEmail = async (req: Request, res: Response) => { logger.info(`Verifying email: ${email}`); if (!user || !user.emailVerificationTokenExpiry || !user.emailVerificationToken) { - logger.warn( - `Failed to find a user for this email verification token - ${verificationToken}:${email}:${phone}`, - ); + logger.warn(`Failed to find a user for this email verification token`); await AuthEventService.log({ userId: user.id, type: 'verify_otp_suspicious', @@ -359,7 +349,7 @@ export const verifyEmail = async (req: Request, res: Response) => { } if (!verificationToken) { - logger.warn(`Missing verification token ${req.body}`); + logger.warn('Missing verification token'); await AuthEventService.log({ userId: user.id, type: 'verify_otp_suspicious', @@ -424,11 +414,7 @@ export const verifyEmail = async (req: Request, res: Response) => { } return res.json({ message: 'Success' }); } else { - logger.error( - `Verfication tokens did not match ${verificationToken} vs ${user.emailVerificationToken} or ${ - user.emailVerificationTokenExpiry - } is less than ${new Date().getTime()}`, - ); + logger.error(`Verification tokens did not match or expired for email verification`); } return res.status(500).json({ error: 'Internal server error' }); @@ -448,7 +434,7 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { logger.info(`Verifying login phone number: ${phone}`); if (!user || !user.phoneVerificationTokenExpiry || !user.phoneVerificationToken) { - logger.warn(`Failed to find a user for this phone verification token - ${verificationToken}`); + logger.warn('Failed to find a user for this phone verification token'); await AuthEventService.log({ userId: user.id, type: 'verify_otp_suspicious', @@ -513,11 +499,7 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { } return res.json({ message: 'Success' }); } else { - logger.warn( - `Verfication tokens did not match ${verificationToken} vs ${ - user.phoneVerificationToken - } or ${user.phoneVerificationTokenExpiry} is less than ${new Date().getTime()}`, - ); + logger.warn(`Verification tokens did not match or expired for login phone verification`); await AuthEventService.log({ userId: user.id, type: 'verify_otp_failed', @@ -546,9 +528,7 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { logger.info(`Verifying login email: ${email}`); if (!user || !user.emailVerificationTokenExpiry || !user.emailVerificationToken) { - logger.warn( - `Failed to find a user for this email verification token - ${verificationToken}:${email}:${phone}`, - ); + logger.warn(`Failed to find a user for this email verification token`); await AuthEventService.log({ userId: user.id, type: 'verify_otp_suspicious', @@ -559,7 +539,7 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { } if (!verificationToken) { - logger.warn(`Missing verification token ${req.body}`); + logger.warn('Missing verification token'); await AuthEventService.log({ userId: user.id, type: 'verify_otp_suspicious', @@ -623,11 +603,7 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { } return res.json({ message: 'Success' }); } else { - logger.error( - `Verfication tokens did not match ${verificationToken} vs ${user.emailVerificationToken} or ${ - user.emailVerificationTokenExpiry - } is less than ${new Date().getTime()}`, - ); + logger.error(`Verification tokens did not match or expired for login email verification`); await AuthEventService.log({ userId: user.id, type: 'verify_otp_failed', diff --git a/src/controllers/registration.ts b/src/controllers/registration.ts index 5a6afec..a9b5eec 100644 --- a/src/controllers/registration.ts +++ b/src/controllers/registration.ts @@ -9,6 +9,7 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { setBootstrapCookie } from '../lib/bootstrapCookie.js'; import { setAuthCookies } from '../lib/cookie.js'; +import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { signEphemeralToken } from '../lib/token.js'; import { AuthEvent } from '../models/authEvents.js'; import { User } from '../models/users.js'; @@ -19,15 +20,10 @@ import { isValidEmail, isValidPhoneNumber, normalizePhoneNumber } from '../utils const logger = getLogger('registration'); const AUTH_MODE = process.env.AUTH_MODE; -const EXTERNAL_DELIVERY_HEADER = 'x-seamless-auth-delivery-mode'; - -function wantsExternalDelivery(req: Request) { - return req.get(EXTERNAL_DELIVERY_HEADER)?.toLowerCase() === 'external'; -} export const register = async (req: Request, res: Response) => { const { email, phone, bootstrapToken } = req.body; - const useExternalDelivery = wantsExternalDelivery(req); + const useExternalDelivery = await canReturnExternalDelivery(req); const normalizedEmail = email?.toLowerCase(); const normalizedPhone = typeof phone === 'string' ? normalizePhoneNumber(phone) : null; diff --git a/src/controllers/user.ts b/src/controllers/user.ts index 28ead99..5ded4d1 100644 --- a/src/controllers/user.ts +++ b/src/controllers/user.ts @@ -173,7 +173,7 @@ export const updateCredential = async (req: Request, res: Response) => { return res.json({ message: 'Credential updated', credential: cred }); } catch (err) { - logger.error(err); + logger.error(`Failed to update credential: ${err}`); return res.status(500).json({ error: 'Failed to update credential' }); } }; @@ -210,7 +210,7 @@ export const deleteCredential = async (req: Request, res: Response) => { return res.json({ message: 'Credential deleted' }); } catch (err) { - console.error(err); + logger.error(`Failed to delete credential: ${err}`); return res.status(500).json({ error: 'Failed to delete credential' }); } }; diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index de327c3..8fbc358 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -78,7 +78,7 @@ const registerWebAuthn = async (req: Request, res: Response) => { logger.info(`Registering passwordless mechanism for ${authReq.user?.email}`); if (!verifiedUser) { - logger.error(`Invalid registration user attempt ${JSON.stringify(req)}`); + logger.error('Invalid registration user attempt'); await AuthEventService.log({ userId: null, type: 'webauthn_registration_suspicious', @@ -164,7 +164,7 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { const { attestationResponse, metadata = {} } = req.body; if (!verifiedUser) { - logger.warn(`Missing verification token ${req.body}`); + logger.warn('Missing attestation response for WebAuthn registration'); await AuthEvent.create({ user_id: null, type: 'registration_failed', @@ -418,9 +418,8 @@ const generateWebAuthn = async (req: Request, res: Response) => { return res.json(options); } catch (error) { if (error instanceof Error) { - logger.error(`stack ${error.stack}`); + logger.error('Failed to generate options for login stack trace redacted'); } - logger.error(`Failed to generate options for login: ${error}.`); await AuthEvent.create({ user_id: null, type: 'login_failed', diff --git a/src/lib/externalDelivery.ts b/src/lib/externalDelivery.ts new file mode 100644 index 0000000..6c2921b --- /dev/null +++ b/src/lib/externalDelivery.ts @@ -0,0 +1,59 @@ +/* + * 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 } from 'express'; + +import { validateInternalServiceToken } from '../middleware/authenticateServiceToken.js'; + +const EXTERNAL_DELIVERY_HEADER = 'x-seamless-auth-delivery-mode'; +const SERVICE_TOKEN_HEADER = 'x-seamless-service-token'; +const INCLUDE_SENSITIVE_HEADER = 'x-seamless-auth-include-sensitive'; + +function extractBearerToken(headerValue: string | undefined): string | null { + if (!headerValue) { + return null; + } + + if (headerValue.startsWith('Bearer ')) { + return headerValue.slice('Bearer '.length).trim() || null; + } + + return headerValue.trim() || null; +} + +export function wantsExternalDelivery(req: Request) { + return req.get(EXTERNAL_DELIVERY_HEADER)?.toLowerCase() === 'external'; +} + +export async function canReturnExternalDelivery(req: Request) { + if (!wantsExternalDelivery(req)) { + return false; + } + + if (process.env.NODE_ENV !== 'production') { + return true; + } + + const serviceToken = extractBearerToken(req.get(SERVICE_TOKEN_HEADER) ?? undefined); + + if (!serviceToken) { + return false; + } + + const decoded = await validateInternalServiceToken(serviceToken); + + return Boolean( + decoded?.sub && decoded.iss === 'seamless-portal-api' && decoded.aud === 'seamless-auth', + ); +} + +export function canReturnSensitiveDevelopmentDetails(req: Request) { + if (process.env.NODE_ENV === 'production') { + return false; + } + + return req.get(INCLUDE_SENSITIVE_HEADER)?.toLowerCase() === 'true'; +} diff --git a/src/middleware/verifyBearerAuth.ts b/src/middleware/verifyBearerAuth.ts index f855413..cc60bc0 100644 --- a/src/middleware/verifyBearerAuth.ts +++ b/src/middleware/verifyBearerAuth.ts @@ -35,7 +35,7 @@ export async function verifyBearerAuth(req: Request, res: Response, next: NextFu } next(); } catch (err) { - console.error('verifyBearerAuth failed:', err); + logger.error(`verifyBearerAuth failed: ${err}`); res.status(401).json({ error: 'unauthorized' }); } } diff --git a/src/models/authEvents.ts b/src/models/authEvents.ts index 869a0c9..3dba06d 100644 --- a/src/models/authEvents.ts +++ b/src/models/authEvents.ts @@ -7,6 +7,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { DataTypes, Model, Optional, Sequelize } from 'sequelize'; +import { redactMetadata } from '../utils/redaction.js'; + export interface AuthEventAttributes { id: string; user_id?: string | null; @@ -85,6 +87,16 @@ const initializeAuthEventModel = (sequelize: Sequelize) => { modelName: 'AuthEvent', tableName: 'auth_events', underscored: true, + hooks: { + beforeValidate(event) { + event.metadata = redactMetadata(event.metadata); + }, + beforeBulkCreate(events) { + for (const event of events) { + event.metadata = redactMetadata(event.metadata); + } + }, + }, }, ); diff --git a/src/schemas/bootstrap.schema.ts b/src/schemas/bootstrap.schema.ts index 6be5bd4..84d7596 100644 --- a/src/schemas/bootstrap.schema.ts +++ b/src/schemas/bootstrap.schema.ts @@ -15,9 +15,9 @@ export const BootstrapAdminInviteBodySchema = z.object({ export const BootstrapAdminInviteResponseSchema = z.object({ success: z.literal(true), data: z.object({ - url: z.url(), + url: z.url().optional(), expiresAt: z.iso.datetime(), - token: z.string().min(32), + token: z.string().min(32).optional(), delivery: AuthDeliverySchema.optional(), }), }); diff --git a/src/services/authEventSerialization.ts b/src/services/authEventSerialization.ts new file mode 100644 index 0000000..7a56e1e --- /dev/null +++ b/src/services/authEventSerialization.ts @@ -0,0 +1,29 @@ +/* + * 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 { redactMetadata } from '../utils/redaction.js'; + +export function serializeAuthEvent(event: unknown) { + const raw = + event && typeof event === 'object' && 'toJSON' in event && typeof event.toJSON === 'function' + ? event.toJSON() + : event; + + if (!raw || typeof raw !== 'object') { + return raw; + } + + const serialized = raw as Record; + + return { + ...serialized, + metadata: redactMetadata(serialized.metadata as Record | null | undefined), + }; +} + +export function serializeAuthEvents(events: unknown[]) { + return events.map(serializeAuthEvent); +} diff --git a/src/services/authEventService.ts b/src/services/authEventService.ts index 3960791..3841c6c 100644 --- a/src/services/authEventService.ts +++ b/src/services/authEventService.ts @@ -9,6 +9,7 @@ import { Request } from 'express'; import { AuthEvent } from '../models/authEvents.js'; import type { AuthEventType } from '../schemas/authEvent.types.js'; import getLogger from '../utils/logger.js'; +import { redactMetadata } from '../utils/redaction.js'; const logger = getLogger('authEventService'); @@ -55,7 +56,7 @@ export class AuthEventService { type: normalizeAuthEventType(type), ip_address: ipAddress || 'unknown', user_agent: userAgent || 'unknown', - metadata, + metadata: redactMetadata(metadata), }); } catch (err) { logger.error('Failed to write AuthEvent:', err); diff --git a/src/services/bootstrapService.ts b/src/services/bootstrapService.ts index a0d5563..67525c7 100644 --- a/src/services/bootstrapService.ts +++ b/src/services/bootstrapService.ts @@ -135,7 +135,10 @@ export async function createAdminBootstrapInvite(params: { const { origins } = await getSystemConfig(); const registrationUrl = `${origins[0]}/login?bootstrapToken=${rawToken}`; - if (process.env.NODE_ENV === 'development') { + if ( + process.env.NODE_ENV === 'development' && + process.env.SEAMLESS_AUTH_DEBUG_SECRETS === 'true' + ) { logger.info('invite link: ', registrationUrl); } diff --git a/src/services/messagingService.ts b/src/services/messagingService.ts index e9f0ed7..7b30836 100644 --- a/src/services/messagingService.ts +++ b/src/services/messagingService.ts @@ -24,7 +24,7 @@ async function getMessagingService() { } export const sendOTPEmail = async (to: string, token: string) => { - logger.debug(`Sending verification email to: ${to} with ${token}`); + logger.debug(`Sending verification email to: ${to}`); if (shouldBypassDirectMessaging()) { logger.debug('Skipping direct email delivery in development'); @@ -46,7 +46,7 @@ export const sendOTPEmail = async (to: string, token: string) => { }; export const sendOTPSMS = async (to: string, token: number) => { - logger.debug(`Sending verification SMS: ${to} with ${token}`); + logger.debug(`Sending verification SMS to: ${to}`); if (shouldBypassDirectMessaging()) { logger.debug('Skipping direct SMS delivery in development'); @@ -71,7 +71,7 @@ export const sendOTPSMS = async (to: string, token: number) => { }; export const sendMagicLinkEmail = async (to: string, token: string, safeRedirect: string) => { - logger.debug(`Sending magic link to: ${to}. URL: ${safeRedirect}`); + logger.debug(`Sending magic link to: ${to}`); if (shouldBypassDirectMessaging()) { logger.debug('Skipping direct magic link delivery in development'); @@ -92,7 +92,7 @@ export const sendMagicLinkEmail = async (to: string, token: string, safeRedirect }; export const sendBootstrapEmail = async (to: string, url: string) => { - logger.debug(`Sending bootsrap invitation email to: ${to}. URL: ${url}`); + logger.debug(`Sending bootstrap invitation email to: ${to}`); if (shouldBypassDirectMessaging()) { logger.debug('Skipping direct bootstrap delivery in development'); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index e9d6e90..35104f0 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -8,6 +8,8 @@ import fs from 'fs'; import path from 'path'; import { createLogger, format, Logger, transports } from 'winston'; +import { redactSensitiveText } from './redaction.js'; + const { combine, timestamp, printf } = format; const loggers: Record = {}; @@ -23,7 +25,11 @@ export default function getLogger(moduleName: string): Logger { const isProd = process.env.NODE_ENV === 'production'; const logFormat = printf(({ level, message, timestamp }) => { - return `${timestamp} [${moduleName}] ${level.toUpperCase()} - ${message}`; + const renderedMessage = + typeof message === 'string' + ? redactSensitiveText(message) + : redactSensitiveText(String(message)); + return `${timestamp} [${moduleName}] ${level.toUpperCase()} - ${renderedMessage}`; }); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/utils/redaction.ts b/src/utils/redaction.ts new file mode 100644 index 0000000..95171fd --- /dev/null +++ b/src/utils/redaction.ts @@ -0,0 +1,80 @@ +/* + * 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 + */ + +export const REDACTED = '[REDACTED]'; + +const MAX_DEPTH = 8; +const MAX_ARRAY_ITEMS = 50; + +const SENSITIVE_KEY_PATTERN = + /(^|_|\b)(access[_-]?token|assertion|authorization|bearer|bootstrap[_-]?token|challenge|client[_-]?secret|code|cookie|ciphertext|email|email[_-]?address|email[_-]?verification[_-]?token|ephemeral[_-]?token|id[_-]?token|identifier|invite[_-]?url|iv|magic[_-]?link[_-]?url|magic[_-]?token|nonce|otp|password|phone|phone[_-]?number|phone[_-]?verification[_-]?token|prf|prf[_-]?(output|result|results|salt)|private[_-]?key|recovery[_-]?code|refresh[_-]?token|salt|secret|state|tag|token|totp[_-]?secret|verification[_-]?token)$/i; + +const TOKEN_TEXT_PATTERNS: Array<[RegExp, string]> = [ + [/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, REDACTED], + [/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, `$1${REDACTED}`], + [/(\/magic-link\/verify\/)[^/?#\s]+/gi, `$1${REDACTED}`], + [/([?&](?:token|bootstrapToken|state|code|salt)=)[^&#\s]+/gi, `$1${REDACTED}`], + [ + /\b((?:token|bootstrapToken|verificationToken|identifier|phone|state|code|secret|salt)\s*[:=]\s*)[^,&\s}]+/gi, + `$1${REDACTED}`, + ], + [/\b(client_secret=)[^&\s]+/gi, `$1${REDACTED}`], +]; + +export function isSensitiveKey(key: string) { + return SENSITIVE_KEY_PATTERN.test(key); +} + +export function redactSensitiveText(value: string) { + return TOKEN_TEXT_PATTERNS.reduce( + (current, [pattern, replacement]) => current.replace(pattern, replacement), + value, + ); +} + +export function redactSensitiveValue(value: unknown, depth = 0): unknown { + if (value === null || value === undefined) { + return value; + } + + if (typeof value === 'string') { + return redactSensitiveText(value); + } + + if (typeof value !== 'object') { + return value; + } + + if (depth >= MAX_DEPTH) { + return '[REDACTED_DEPTH_LIMIT]'; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return value.slice(0, MAX_ARRAY_ITEMS).map((item) => redactSensitiveValue(item, depth + 1)); + } + + const redacted: Record = {}; + + for (const [key, nestedValue] of Object.entries(value as Record)) { + redacted[key] = isSensitiveKey(key) ? REDACTED : redactSensitiveValue(nestedValue, depth + 1); + } + + return redacted; +} + +export function redactMetadata | null | undefined>( + metadata: T, +): T { + if (metadata === null || metadata === undefined) { + return metadata; + } + + return redactSensitiveValue(metadata) as T; +} diff --git a/tests/integration/bootstrap/bootstrap.spec.ts b/tests/integration/bootstrap/bootstrap.spec.ts index 5bcdc13..6ade766 100644 --- a/tests/integration/bootstrap/bootstrap.spec.ts +++ b/tests/integration/bootstrap/bootstrap.spec.ts @@ -61,8 +61,30 @@ it('creates bootstrap invite successfully', async () => { }), ); + expect(res.body.success).toBe(true); + expect(res.body.data.expiresAt).toBeDefined(); + expect(res.body.data.url).toBeUndefined(); + expect(res.body.data.token).toBeUndefined(); +}); + +it('returns bootstrap invite token details only when explicitly requested in non-production', 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') + .set('x-seamless-auth-include-sensitive', 'true') + .send({ email: 'test@example.com' }); + + expect(res.status).toBe(201); expect(res.body.success).toBe(true); expect(res.body.data.url).toContain('bootstrapToken'); + expect(res.body.data.token).toBe('test-secret-that-is-very-long-very-very-very-long'); }); it('fails when missing bearer token', async () => { diff --git a/tests/unit/lib/externalDelivery.spec.ts b/tests/unit/lib/externalDelivery.spec.ts new file mode 100644 index 0000000..d65055b --- /dev/null +++ b/tests/unit/lib/externalDelivery.spec.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/middleware/authenticateServiceToken.js', () => ({ + validateInternalServiceToken: vi.fn(), +})); + +import { validateInternalServiceToken } from '../../../src/middleware/authenticateServiceToken.js'; +import { + canReturnExternalDelivery, + canReturnSensitiveDevelopmentDetails, +} from '../../../src/lib/externalDelivery.js'; + +function req(headers: Record) { + return { + get: (name: string) => headers[name.toLowerCase()], + } as any; +} + +describe('external delivery gates', () => { + const originalNodeEnv = process.env.NODE_ENV; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.NODE_ENV = 'test'; + }); + + afterEach(() => { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + }); + + it('allows explicit external delivery outside production', async () => { + await expect( + canReturnExternalDelivery( + req({ + 'x-seamless-auth-delivery-mode': 'external', + }), + ), + ).resolves.toBe(true); + }); + + it('requires a valid internal service token in production', async () => { + process.env.NODE_ENV = 'production'; + (validateInternalServiceToken as any).mockResolvedValue({ + sub: 'service', + iss: 'seamless-portal-api', + aud: 'seamless-auth', + }); + + await expect( + canReturnExternalDelivery( + req({ + 'x-seamless-auth-delivery-mode': 'external', + 'x-seamless-service-token': 'Bearer service-token', + }), + ), + ).resolves.toBe(true); + + expect(validateInternalServiceToken).toHaveBeenCalledWith('service-token'); + }); + + it('blocks external delivery in production without a trusted service token', async () => { + process.env.NODE_ENV = 'production'; + (validateInternalServiceToken as any).mockResolvedValue(null); + + await expect( + canReturnExternalDelivery( + req({ + 'x-seamless-auth-delivery-mode': 'external', + 'x-seamless-service-token': 'Bearer invalid', + }), + ), + ).resolves.toBe(false); + }); + + it('requires explicit sensitive-details opt-in outside production', () => { + expect( + canReturnSensitiveDevelopmentDetails( + req({ + 'x-seamless-auth-include-sensitive': 'true', + }), + ), + ).toBe(true); + }); +}); diff --git a/tests/unit/services/authEventSerialization.spec.ts b/tests/unit/services/authEventSerialization.spec.ts new file mode 100644 index 0000000..bce459e --- /dev/null +++ b/tests/unit/services/authEventSerialization.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { serializeAuthEvent } from '../../../src/services/authEventSerialization.js'; + +describe('auth event serialization', () => { + it('redacts legacy sensitive metadata when returning auth events', () => { + const event = { + toJSON: () => ({ + id: 'event-1', + type: 'system_config_updated', + metadata: { + before: { + email: 'user@example.com', + phoneVerificationToken: '123456', + }, + after: { + roles: ['admin'], + prfSalt: 'salt-value', + }, + }, + }), + }; + + expect(serializeAuthEvent(event)).toEqual({ + id: 'event-1', + type: 'system_config_updated', + metadata: { + before: { + email: '[REDACTED]', + phoneVerificationToken: '[REDACTED]', + }, + after: { + roles: ['admin'], + prfSalt: '[REDACTED]', + }, + }, + }); + }); +}); diff --git a/tests/unit/services/authEventService.spec.ts b/tests/unit/services/authEventService.spec.ts index 16c2a72..86215fc 100644 --- a/tests/unit/services/authEventService.spec.ts +++ b/tests/unit/services/authEventService.spec.ts @@ -244,4 +244,53 @@ describe('AuthEventService', () => { metadata: { reason: 'legacy typo' }, }); }); + + it('redacts sensitive metadata before writing events', async () => { + const { AuthEvent } = await import('../../../src/models/authEvents.js'); + const { AuthEventService } = await import('../../../src/services/authEventService.js'); + + const req = buildReq(); + + await AuthEventService.log({ + type: 'system_config_updated', + req, + metadata: { + before: { + email: 'user@example.com', + phone: '+15555550123', + emailVerificationToken: '111111', + oauth: { + clientSecret: 'oauth-secret', + clientSecretEnv: 'GOOGLE_CLIENT_SECRET', + }, + }, + after: { + prf: { salt: 'salt-value', output: 'derived-secret' }, + scopes: ['admin:read'], + }, + message: 'Token: abc123 user@example.com', + }, + }); + + expect(AuthEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { + before: { + email: '[REDACTED]', + phone: '[REDACTED]', + emailVerificationToken: '[REDACTED]', + oauth: { + clientSecret: '[REDACTED]', + clientSecretEnv: 'GOOGLE_CLIENT_SECRET', + }, + }, + after: { + prf: '[REDACTED]', + scopes: ['admin:read'], + }, + message: 'Token: [REDACTED] [REDACTED]', + }, + }), + ); + }); }); diff --git a/tests/unit/utils/redaction.spec.ts b/tests/unit/utils/redaction.spec.ts new file mode 100644 index 0000000..2cfa113 --- /dev/null +++ b/tests/unit/utils/redaction.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { REDACTED, redactMetadata, redactSensitiveText } from '../../../src/utils/redaction.js'; + +describe('redaction utilities', () => { + it('redacts sensitive keys while preserving operational metadata', () => { + expect( + redactMetadata({ + providerId: 'google', + email: 'user@example.com', + phone: '+15555550123', + accessToken: 'access-token', + refresh_token: 'refresh-token', + totpSecret: 'totp-secret', + prf: { + salt: 'salt-value', + results: { + first: 'local-key-material', + }, + }, + oauth: { + clientSecret: 'secret-value', + clientSecretEnv: 'GOOGLE_CLIENT_SECRET', + }, + scopes: ['admin:read'], + }), + ).toEqual({ + providerId: 'google', + email: REDACTED, + phone: REDACTED, + accessToken: REDACTED, + refresh_token: REDACTED, + totpSecret: REDACTED, + prf: REDACTED, + oauth: { + clientSecret: REDACTED, + clientSecretEnv: 'GOOGLE_CLIENT_SECRET', + }, + scopes: ['admin:read'], + }); + }); + + it('redacts token, salt, code, and email values embedded in text', () => { + expect( + redactSensitiveText( + 'Bearer abc.def.ghi /magic-link/verify/magic-token token=magic-token code=oauth-code salt=prf-salt user@example.com', + ), + ).toBe( + 'Bearer [REDACTED] /magic-link/verify/[REDACTED] token=[REDACTED] code=[REDACTED] salt=[REDACTED] [REDACTED]', + ); + }); +});