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
12 changes: 12 additions & 0 deletions .changeset/admin-identity-change-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'seamless-auth-api': patch
---

Changing a user's email or phone through `PATCH /admin/users/{userId}` now signs the user out and tells them (#475).

- Every active session for the user is revoked, in the same transaction as the change. Access tokens already issued stay valid until they expire.
- A notice goes to the previous address and to the new one, by email for an email change and by SMS for a phone change, through the configured direct transports. A failed notice does not fail the request.
- The `internal_user_updated_by_owner` audit event records `identityChanges` (SHA-256 digests of the previous and new values), `revokedSessions` and the outcome of each notice.
- With `x-seamless-auth-delivery-mode: external` no notice is sent, because the delivery payload has no kind for account notices yet. Each is recorded as `not_sent_external_delivery`.

The response shape is unchanged.
22 changes: 22 additions & 0 deletions docs/admin-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,28 @@ no one able to administer it.
Instances whose `available_roles` predates scoped roles and lists only `admin` get `admin`
instead, which is equivalent in power, so the grant never silently becomes a no-op.

## Changing A User's Email Or Phone

When `PATCH /admin/users/:userId` changes a user's email address or phone number, the API also:

- revokes every active session for that user, in the same transaction as the change, so the
user is signed out everywhere (access tokens already issued stay valid until they expire)
- sends a notice to the previous address and to the new one, by email for an email change and
by SMS for a phone change
- records the change on the `internal_user_updated_by_owner` audit event as `identityChanges`,
with a SHA-256 digest of the previous and new values (audit metadata never stores addresses),
plus `revokedSessions` and the outcome of each notice in `notices`

An email that differs only in letter case is not treated as a change. Setting `emailVerified`,
`phoneVerified` or roles on their own does not revoke sessions.

Notices go through the configured direct email and SMS transports. The messaging contract has
no template for account notices, so the text is fixed by the API. A notice that fails to send
does not fail the request; it is recorded as `failed` in `notices`. A caller that sends
`x-seamless-auth-delivery-mode: external` owns delivery, and the delivery payload has no kind
for account notices yet, so in that mode no notice is sent and each is recorded as
`not_sent_external_delivery`.

## Device Replacement Recovery

Administrators with write access can prepare an account for device replacement:
Expand Down
1 change: 1 addition & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4163,6 +4163,7 @@
"/admin/users/{userId}": {
"patch": {
"summary": "Update user",
"description": "Changing `email` or `phone` revokes all of the user's sessions and sends a notice to the previous and the new address. With `x-seamless-auth-delivery-mode: external` no notice is sent.",
"tags": ["Admin"],
"security": [{ "bearerAuth": [] }],
"requestBody": {
Expand Down
10 changes: 10 additions & 0 deletions src/config/directMessaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import {
type AuthMessagingService,
createAuthMessagingService,
type EmailTransport,
MessagingConfigurationError,
type SmsTransport,
} from '@seamless-auth/messaging';
import { createAwsEmailTransport, createAwsSmsTransport } from '@seamless-auth/messaging-aws';
import { createTwilioSmsTransport } from '@seamless-auth/messaging-twilio';
Expand Down Expand Up @@ -114,3 +116,11 @@ export function createDirectAuthMessagingService(appName: string): AuthMessaging
sms: buildSmsTransport(),
});
}

export function createDirectEmailTransport(): EmailTransport {
return buildEmailTransport();
}

export function createDirectSmsTransport(): SmsTransport {
return buildSmsTransport();
}
36 changes: 35 additions & 1 deletion src/controllers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Request, Response } from 'express';
import { Op, UniqueConstraintError, WhereOptions } from 'sequelize';

import { getSystemConfig } from '../config/getSystemConfig.js';
import { wantsExternalDelivery } from '../lib/externalDelivery.js';
import { unavailableRoles } from '../lib/scopedRoles.js';
import { AuthEvent, AuthEventAttributes } from '../models/authEvents.js';
import { Credential } from '../models/credentials.js';
Expand All @@ -34,6 +35,12 @@ import { streamAuthEventExport } from '../services/auditExport.js';
import { verifyAuthEventChain } from '../services/auditIntegrity.js';
import { serializeAuthEvents } from '../services/authEventSerialization.js';
import { AuthEventService } from '../services/authEventService.js';
import {
auditIdentityChanges,
detectIdentityChanges,
revokeSessionsForIdentityChange,
sendIdentityChangeNotices,
} from '../services/identityChangeService.js';
import { revokeAllSessionsForUsers } from '../services/sessionRevocation.js';
import { hardRevokeSession } from '../services/sessionService.js';
import { importUsers as runUserImport } from '../services/userImportService.js';
Expand Down Expand Up @@ -355,8 +362,28 @@ export const updateUser = async (req: ServiceRequest, res: Response) => {
});
}

const identityChanges = detectIdentityChanges(user, updateData);

try {
await user.update(updateData);
let revokedSessions = 0;

if (identityChanges.length > 0) {
// One transaction, so a failed revocation cannot leave the new address stored
// and the change undetectable on a retry.
await User.sequelize!.transaction(async (transaction) => {
await user.update(updateData, { transaction });
revokedSessions = await revokeSessionsForIdentityChange(user.id, transaction);
});
} else {
await user.update(updateData);
}

const notices =
identityChanges.length > 0
? await sendIdentityChangeNotices(identityChanges, {
external: wantsExternalDelivery(req),
})
: [];

await AuthEventService.log({
userId,
Expand All @@ -367,6 +394,13 @@ export const updateUser = async (req: ServiceRequest, res: Response) => {
before,
after: updateData,
targetUser: userId,
...(identityChanges.length > 0
? {
identityChanges: auditIdentityChanges(identityChanges),
revokedSessions,
notices,
}
: {}),
},
});
} catch (error) {
Expand Down
5 changes: 4 additions & 1 deletion src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4068,7 +4068,10 @@ export interface paths {
delete?: never;
options?: never;
head?: never;
/** Update user */
/**
* Update user
* @description Changing `email` or `phone` revokes all of the user's sessions and sends a notice to the previous and the new address. With `x-seamless-auth-delivery-mode: external` no notice is sent.
*/
patch: {
parameters: {
query?: never;
Expand Down
2 changes: 2 additions & 0 deletions src/routes/admin.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,8 @@ adminRouter.patch(
{
auth: 'access',
summary: 'Update user',
description:
"Changing `email` or `phone` revokes all of the user's sessions and sends a notice to the previous and the new address. With `x-seamless-auth-delivery-mode: external` no notice is sent.",
tags: ['Admin'],
middleware: [requireAdmin('write')],

Expand Down
147 changes: 147 additions & 0 deletions src/services/identityChangeService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the Apache License, Version 2.0
* See LICENSE file in the project root for full license information
*/

import type { Transaction } from 'sequelize';

import { getSystemConfig } from '../config/getSystemConfig.js';
import getLogger from '../utils/logger.js';
import { hashSha256 } from '../utils/utils.js';
import { DeliveryError } from './deliveryError.js';
import { sendNoticeEmail, sendNoticeSms } from './messagingService.js';
import { revokeAllSessionsForUsers } from './sessionRevocation.js';

const logger = getLogger('identityChange');

export type IdentityField = 'email' | 'phone';

export interface IdentityChange {
field: IdentityField;
previous: string | null;
next: string | null;
}

export type IdentityNoticeStatus = 'sent' | 'failed' | 'not_sent_external_delivery';

export interface IdentityNoticeResult {
field: IdentityField;
recipient: 'previous' | 'new';
status: IdentityNoticeStatus;
}

function sameEmail(a: string | null, b: string | null) {
return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
}

export function detectIdentityChanges(
current: { email: string | null; phone: string | null },
update: Record<string, unknown>,
): IdentityChange[] {
const changes: IdentityChange[] = [];

if (typeof update.email === 'string' && !sameEmail(current.email, update.email)) {
changes.push({ field: 'email', previous: current.email, next: update.email });
}

if ('phone' in update) {
const next = typeof update.phone === 'string' ? update.phone : null;

if (next !== (current.phone ?? null)) {
changes.push({ field: 'phone', previous: current.phone ?? null, next });
}
}

return changes;
}

/**
* Audit metadata is redacted of addresses before it is stored, so the values are kept
* as digests of the normalized form. An investigator can still confirm whether a given
* address was the one removed or added.
*/
export function auditIdentityChanges(changes: IdentityChange[]) {
const digest = (field: IdentityField, value: string | null) =>
value === null ? null : hashSha256(field === 'email' ? value.toLowerCase() : value);

return changes.map(({ field, previous, next }) => ({
field,
previousSha256: digest(field, previous),
nextSha256: digest(field, next),
}));
}

export async function revokeSessionsForIdentityChange(
userId: string,
transaction?: Transaction,
): Promise<number> {
return revokeAllSessionsForUsers(userId, 'admin_identity_change', transaction);
}

function noticeText(appName: string, field: IdentityField, recipient: 'previous' | 'new') {
const label = field === 'email' ? 'email address' : 'phone number';

if (recipient === 'previous') {
return `An administrator changed the ${label} on your ${appName} account, and every session on it was signed out. If you did not expect this change, contact your administrator.`;
}

return `An administrator set this ${label} on an ${appName} account. If you did not expect this, contact your administrator.`;
}

async function deliver(
field: IdentityField,
to: string,
appName: string,
recipient: 'previous' | 'new',
) {
const text = noticeText(appName, field, recipient);

if (field === 'email') {
await sendNoticeEmail(to, `Your ${appName} sign-in email was changed`, text);
} else {
await sendNoticeSms(to, text);
}
}

/**
* A notice that fails to send does not undo the change or fail the request: the change
* is already stored and the sessions are already revoked, so the outcome is recorded in
* the audit event instead.
*/
export async function sendIdentityChangeNotices(
changes: IdentityChange[],
{ external }: { external: boolean },
): Promise<IdentityNoticeResult[]> {
const results: IdentityNoticeResult[] = [];
const appName = (await getSystemConfig())?.app_name ?? 'Seamless Auth';

for (const change of changes) {
const targets: Array<['previous' | 'new', string | null]> = [
['previous', change.previous],
['new', change.next],
];

for (const [recipient, to] of targets) {
if (!to) continue;

// The delivery contract has no kind for account notices, so a caller that owns
// delivery cannot be handed one. Sending directly would bypass that caller.
if (external) {
results.push({ field: change.field, recipient, status: 'not_sent_external_delivery' });
continue;
}

try {
await deliver(change.field, to, appName, recipient);
results.push({ field: change.field, recipient, status: 'sent' });
} catch (error) {
if (!(error instanceof DeliveryError)) throw error;
logger.error(`Identity change notice failed for ${change.field} (${recipient})`);
results.push({ field: change.field, recipient, status: 'failed' });
}
}
}

return results;
}
53 changes: 51 additions & 2 deletions src/services/messagingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
* See LICENSE file in the project root for full license information
*/

import type { AuthMessagingService } from '@seamless-auth/messaging';
import type { AuthMessagingService, EmailTransport, SmsTransport } from '@seamless-auth/messaging';

import { createDirectAuthMessagingService } from '../config/directMessaging.js';
import {
createDirectAuthMessagingService,
createDirectEmailTransport,
createDirectSmsTransport,
} from '../config/directMessaging.js';
import { getSystemConfig } from '../config/getSystemConfig.js';
import getLogger from '../utils/logger.js';
import { normalizePhoneNumber } from '../utils/utils.js';
Expand Down Expand Up @@ -131,3 +135,48 @@ export const sendEnrollmentInviteEmail = async (to: string, signInUrl: string) =
throw new DeliveryError('Failed to send enrollment invite email', error);
}
};

// The messaging contract has templates only for sign-in messages, so account notices go
// straight to the configured transport with text written here.
let noticeEmailTransport: EmailTransport | null = null;
let noticeSmsTransport: SmsTransport | null = null;

export const sendNoticeEmail = async (to: string, subject: string, text: string) => {
logger.debug('Sending account notice email');

if (shouldBypassDirectMessaging()) {
logger.debug('Skipping direct notice email delivery in development');
return;
}

try {
noticeEmailTransport ??= createDirectEmailTransport();
await noticeEmailTransport.send({ to, subject, text });
} catch (error) {
logger.error(`Failed to send account notice email ${error}`);
throw new DeliveryError('Failed to send account notice email', error);
}
};

export const sendNoticeSms = async (to: string, body: string) => {
logger.debug('Sending account notice SMS');

if (shouldBypassDirectMessaging()) {
logger.debug('Skipping direct notice SMS delivery in development');
return;
}

try {
const normalizedPhone = normalizePhoneNumber(to);

if (!normalizedPhone) {
throw new Error('Invalid phone number for direct SMS delivery');
}

noticeSmsTransport ??= createDirectSmsTransport();
await noticeSmsTransport.send({ to: normalizedPhone, body });
} catch (error) {
logger.error(`Failed to send account notice SMS ${error}`);
throw new DeliveryError('Failed to send account notice SMS', error);
}
};
Loading
Loading