Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 6 additions & 4 deletions src/controllers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -216,7 +218,7 @@ export const getUserDetail = async (req: ServiceRequest, res: Response) => {
user,
sessions,
credentials,
events,
events: serializeAuthEvents(events),
});
};

Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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' });
Expand Down
21 changes: 13 additions & 8 deletions src/controllers/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

import { Request, Response } from 'express';

import {
canReturnExternalDelivery,
canReturnSensitiveDevelopmentDetails,
} from '../lib/externalDelivery.js';
import {
assertBootstrapAllowed,
assertBootstrapSecret,
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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,
Expand All @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion src/controllers/internalSecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -55,7 +56,7 @@ export const getSecurityAnomalies = async (_req: Request, res: Response) => {
});

return res.json({
suspiciousEvents: events,
suspiciousEvents: serializeAuthEvents(events),
total: events.length,
});
} catch {
Expand Down
20 changes: 8 additions & 12 deletions src/controllers/magicLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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() },
Expand All @@ -168,15 +164,15 @@ 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' });
}

await AuthEventService.log({
userId: record.user_id,
type: 'magic_link_success',
req,
metadata: { message: `Token: ${token}` },
metadata: { reason: 'Magic link token consumed' },
});

// Device binding check
Expand Down
8 changes: 4 additions & 4 deletions src/controllers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,15 @@ export async function startOAuthLogin(req: Request, res: Response) {
state,
}),
});
} catch (error) {
} catch {
await AuthEventService.log({
type: 'oauth_login_failed',
req,
metadata: { providerId: provider.id, reason: 'start_failed' },
});

return res.status(400).json({
error: error instanceof Error ? error.message : 'OAuth start failed',
error: 'OAuth start failed',
});
}
}
Expand Down Expand Up @@ -140,15 +140,15 @@ export async function finishOAuthLogin(req: Request, res: Response) {
authMode: AUTH_MODE,
clearExistingCookies: true,
});
} catch (error) {
} catch {
await AuthEventService.log({
type: 'oauth_login_failed',
req,
metadata: { providerId: provider.id, reason: 'callback_failed' },
});

return res.status(400).json({
error: error instanceof Error ? error.message : 'OAuth login failed',
error: 'OAuth login failed',
});
}
}
Loading
Loading