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
11 changes: 11 additions & 0 deletions migrations/2026-07-27_10-00-00_add_password_backup_public_key.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//2026-07-27_10-00-00_add_password_backup_public_key

exports.up = function (db) {
return db.query(
'ALTER TABLE user_devices ADD COLUMN IF NOT EXISTS password_backup_public_key TEXT',
);
};

exports.down = function (db) {
return db.query('ALTER TABLE user_devices DROP COLUMN IF EXISTS password_backup_public_key');
};
19 changes: 19 additions & 0 deletions migrations/2026-07-28_14-11-47_sso_passwordless.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//2026-07-28_14-11-47_sso_passwordless

exports.up = async function (db) {
await db.query(
`ALTER TABLE allowed_emails ADD COLUMN IF NOT EXISTS uses_passwordless_auth BOOLEAN NOT NULL DEFAULT FALSE`,
);
await db.query(
`ALTER TABLE banks ADD COLUMN IF NOT EXISTS ms_entra_vaults_use_passwordless_auth BOOLEAN NOT NULL DEFAULT FALSE`,
);
await db.query(`ALTER TABLE bank_sso_config DROP COLUMN IF EXISTS is_sso_v2`);
};

exports.down = async function (db) {
await db.query(`ALTER TABLE allowed_emails DROP COLUMN IF EXISTS uses_passwordless_auth`);
await db.query(`ALTER TABLE banks DROP COLUMN IF EXISTS ms_entra_vaults_use_passwordless_auth`);
await db.query(
`ALTER TABLE bank_sso_config ADD COLUMN IF NOT EXISTS is_sso_v2 BOOLEAN NOT NULL DEFAULT FALSE`,
);
};
21 changes: 21 additions & 0 deletions src/api2/routes/authentication/authenticateWithOpenidAuthCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,27 @@ export const authenticateWithOpenidAuthCode = async (
res.status(400).end();
return;
}

// // BYPASS CODE FOR DEBUGGING PURPOSES
// if (safeBody.authCode === 'bypass_code') {
// // bypass code for debugging purposes
// const userEmail = youremail@domain.com;
// const openidSession = await SessionStore.createOpenIdSession(
// {
// bankId: bankIds.internalId,
// accessToken: 'bypass_access_token',
// userEmail,
// },
// Date.now() + 3600 * 1000,
// );

// res.status(200).json({
// openidSession,
// email: userEmail,
// });
// return;
// }

const clientId = bankConfigRes.rows[0]!.client_id;

const openidConfig = await fetchOpenIdConfig(safeBody.openidConfigurationUrl);
Expand Down
83 changes: 83 additions & 0 deletions src/api2/routes/authentication/usesPasswordlessUnlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Request, Response } from 'express';
import Joi from 'joi';
import { getBankIds } from '../../helpers/bankUUID';
import { db } from '../../../helpers/db';
import { logError, logInfo } from '../../../helpers/logger';

export const usesPasswordlessUnlock = async (req: Request, res: Response): Promise<void> => {
try {
const joiRes = Joi.object({ userEmail: Joi.string().email().lowercase().required() }).validate(
req.body,
);
if (joiRes.error) {
res.status(400).json({ error: joiRes.error.details });
return;
}
const email = joiRes.value.userEmail;
const bankIds = await getBankIds(req);
const usesPasswordless = await usesPasswordlessUnlockForEmail(email, bankIds.internalId);
if (usesPasswordless === null) {
// return 200 to prevent account enumeration.
res.status(200).json({ usesPasswordlessUnlock: false });
return;
}

res.status(200).json({
usesPasswordlessUnlock: usesPasswordless,
});
return;
} catch (e) {
logError('usesPasswordlessUnlock', e);
// return 200 to prevent account enumeration.
res.status(200).json({ usesPasswordlessUnlock: false });
return;
}
};

export const usesPasswordlessUnlockForEmail = async (
email: string,
bankId: number,
): Promise<boolean | null> => {
const bankRes = await db.query(
`SELECT
b.ms_entra_vaults_use_passwordless_auth,
EXISTS (SELECT 1 FROM bank_sso_config WHERE bank_id = b.id) AS sso_configured
FROM banks AS b
WHERE b.id = $1`,
[bankId],
);
if (bankRes.rowCount === 0) {
logInfo(email, 'usesPasswordlessUnlock fail: bad bank');
return null;
}

const userRes = await db.query(
`SELECT deactivated, ms_entra_id FROM users WHERE email = $1 AND bank_id = $2`,
[email, bankId],
);
if ((userRes.rowCount ?? 0) === 0 || userRes.rows[0].deactivated) {
logInfo(email, 'usesPasswordlessUnlock fail: email not found or deactivated');
return null;
}

const msEntraPasswordless =
bankRes.rows[0].sso_configured &&
bankRes.rows[0].ms_entra_vaults_use_passwordless_auth &&
(userRes.rowCount ?? 0) > 0 &&
userRes.rows[0].ms_entra_id != null;

const patternRes = await db.query(
`SELECT 1 FROM allowed_emails
WHERE bank_id = $1
AND uses_passwordless_auth = true
AND (
(LEFT(pattern, 2) != '*@' AND pattern = $2) OR
(LEFT(pattern, 2) = '*@' AND SPLIT_PART($2, '@', 2) = SUBSTRING(pattern FROM 3))
)
LIMIT 1`,
[bankId, email],
);
const patternPasswordless = (patternRes.rowCount ?? 0) > 0;
const usesPasswordlessUnlock = msEntraPasswordless || patternPasswordless;
return usesPasswordlessUnlock;
};
17 changes: 9 additions & 8 deletions src/api2/routes/bank/getBankConfig.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Request, Response } from 'express';
import { db } from '../../../helpers/db';
import env from '../../../helpers/env';
import { logError, logInfo } from '../../../helpers/logger';
import { getBankIds } from '../../helpers/bankUUID';

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
export const getBankConfig = async (req: any, res: any): Promise<void> => {
export const getBankConfig = async (req: Request, res: Response): Promise<void> => {
try {
const bankIds = await getBankIds(req);
const bankRes = await db.query(
Expand All @@ -16,8 +15,7 @@ export const getBankConfig = async (req: any, res: any): Promise<void> => {
json_agg(
json_build_object(
'openid_configuration_url', sso.openid_configuration_url,
'client_id', sso.client_id,
'is_sso_v2', sso.is_sso_v2
'client_id', sso.client_id
)
) FILTER (WHERE sso.id IS NOT NULL),
'[]'
Expand All @@ -30,17 +28,20 @@ export const getBankConfig = async (req: any, res: any): Promise<void> => {
);
if (bankRes.rowCount === 0) {
logInfo(req.body?.userEmail, 'getBankConfig fail: bad bank');
return res.status(400).end();
res.status(400).end();
return;
}
logInfo(req.body?.userEmail, 'getBankConfig OK');
return res.status(200).json({
res.status(200).json({
newUrl: bankRes.rows[0].redirect_url,
bankName: bankRes.rows[0].name,
preventUpdatePopup: bankRes.rows[0]?.settings?.PREVENT_UPDATE_POPUP || false,
ssoConfigs: bankRes.rows[0]?.sso_configs.length == 0 ? null : bankRes.rows[0]?.sso_configs,
});
return;
} catch (e) {
logError('getBankConfig', e);
return res.status(400).end();
res.status(400).end();
return;
}
};
48 changes: 48 additions & 0 deletions src/api2/routes/data/getVaultData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ export const getVaultData = async (req: any, res: any): Promise<void> => {
}

const sharedVaults = await getSharedVaults(dbRes.rows[0].user_id, bankIds.internalId);
const pendingSsoDevices = await getPendingSsoDevices(
dbRes.rows[0].user_id,
bankIds.internalId,
deviceId,
);

const userResultingSetting = getDefaultSettingOrUserOverride(
dbRes.rows[0].bank_settings,
Expand All @@ -142,6 +147,7 @@ export const getVaultData = async (req: any, res: any): Promise<void> => {
defaultAutolockDelay: userResultingSetting?.defaultAutolockDelay,
maxAutolockDelay: userResultingSetting?.maxAutolockDelay,
sharedVaults,
pendingSsoDevices,
needsPasswordBackup:
!dbRes.rows[0].encrypted_password_backup_2 ||
dbRes.rows[0].encrypted_password_backup_2.length == 512,
Expand Down Expand Up @@ -202,6 +208,48 @@ export const getSharedVaults = async (
}));
};

export const getPendingSsoDevices = async (
userId: number,
bankId: number,
callingDeviceUniqueId: string,
): Promise<
{
deviceId: string;
deviceName: string;
deviceType: string;
osFamily: string;
osNameAndVersion: string;
createdAt: any;
passwordBackupPublicKey: string;
}[]
> => {
const pendingSsoDevicesRes = await db.query(
`SELECT
device_unique_id AS device_id,
device_name,
device_type,
os_family,
os_version AS os_name_and_version,
created_at,
password_backup_public_key
FROM user_devices
WHERE user_id=$1 AND bank_id=$2 AND device_unique_id != $3
AND authorization_status = 'AUTHORIZED'
AND password_backup_public_key IS NOT NULL
AND (encrypted_password_backup_2 IS NULL OR encrypted_password_backup_2 = '')`,
[userId, bankId, callingDeviceUniqueId],
);
return pendingSsoDevicesRes.rows.map((d) => ({
deviceId: d.device_id,
deviceName: d.device_name,
deviceType: d.device_type,
osFamily: d.os_family,
osNameAndVersion: d.os_name_and_version,
createdAt: d.created_at,
passwordBackupPublicKey: d.password_backup_public_key,
}));
};

const cleanChangedEmails = async (userId: number, deviceUniqueId: string, bankId: number) => {
try {
const changedEmails = await db.query(
Expand Down
41 changes: 41 additions & 0 deletions src/api2/routes/deviceAuthorization/rejectSsoDevice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { db } from '../../../helpers/db';
import { logError, logInfo } from '../../../helpers/logger';
import { inputSanitizer } from '../../../helpers/sanitizer';
import { checkBasicAuth2 } from '../../helpers/authorizationChecks';

// Lets an already-authorized device (A) reject a peer device (B) that is itself already
// AUTHORIZED (identity verified via SSO or email) but still waiting for a password backup
// (see getPendingSsoDevices) — ie. before the device ever actually gains access to the vault.
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
export const rejectSsoDevice = async (req: any, res: any) => {
try {
const deviceIdToReject = inputSanitizer.getString(req.body?.deviceIdToReject);
if (!deviceIdToReject) {
logInfo(req.body?.userEmail, 'rejectSsoDevice fail: missing deviceIdToReject');
return res.status(403).end();
}

const basicAuth = await checkBasicAuth2(req);
if (!basicAuth.granted) {
logInfo(req.body?.userEmail, 'rejectSsoDevice fail: auth not granted');
return res.status(401).end();
}

const result = await db.query(
`UPDATE user_devices
SET device_unique_id=null, authorization_status='REVOKED_BY_USER', device_public_key_2=null,
encrypted_password_backup_2='', password_backup_public_key=null, revocation_date=$1
WHERE device_unique_id=$2 AND user_id=$3 AND bank_id=$4
AND authorization_status='AUTHORIZED' AND password_backup_public_key IS NOT NULL`,
[new Date().toISOString(), deviceIdToReject, basicAuth.userId, basicAuth.bankIds.internalId],
);
// applied=false means another authorized device already approved (or also rejected) this
// same device in the meantime.
const applied = (result.rowCount ?? 0) > 0;
logInfo(req.body?.userEmail, 'rejectSsoDevice OK');
return res.status(200).json({ applied });
} catch (e) {
logError(req.body?.userEmail, 'rejectSsoDevice', e);
return res.status(400).end();
}
};
37 changes: 32 additions & 5 deletions src/api2/routes/deviceAuthorization/requestDeviceAccess.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { db } from '../../../helpers/db';
import { getExpirationDate, isExpired } from '../../../helpers/dateHelper';
import { sendDeviceRequestEmail } from '../../../emails/sendDeviceRequestEmail';
import {
sendDeviceRequestAdminEmail,
sendDeviceRequestEmail,
} from '../../../emails/sendDeviceRequestEmail';
import { logError, logInfo } from '../../../helpers/logger';
import { getRandomString } from '../../../helpers/randomString';
import {
Expand Down Expand Up @@ -133,6 +136,22 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => {
}

if (isOpenidAuthenticated) {
const otherActiveDevicesRes = await db.query(
`SELECT COUNT(*) AS device_count FROM user_devices
WHERE user_id=$1 AND bank_id=$2 AND device_unique_id != $3
AND (authorization_status = 'AUTHORIZED' OR authorization_status = 'PENDING' OR
authorization_status = 'USER_VERIFIED_PENDING_ADMIN_CHECK')`,
[userId, bankIds.internalId, safeBody.deviceId],
);
const isAdditionalDevice =
Number.parseInt(otherActiveDevicesRes.rows[0].device_count, 10) >= 1;
const requiresAdminCheck =
isAdditionalDevice &&
!!userRes.rows[0].bank_settings?.REQUIRE_ADMIN_CHECK_FOR_SECOND_DEVICE;
Comment thread
giregk marked this conversation as resolved.
const nextAuthorizationStatus = requiresAdminCheck
? 'USER_VERIFIED_PENDING_ADMIN_CHECK'
: 'AUTHORIZED';

if (!deviceInDb) {
// CREATE AUTHORIZED DEVICE
await db.query(
Expand All @@ -147,7 +166,7 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => {
safeBody.appVersion,
safeBody.deviceId,
safeBody.devicePublicKey,
'AUTHORIZED',
nextAuthorizationStatus,
bankIds.internalId,
],
);
Expand All @@ -163,13 +182,21 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => {
safeBody.osNameAndVersion,
safeBody.appVersion,
safeBody.deviceId,
'AUTHORIZED',
nextAuthorizationStatus,
deviceInDb.id,
],
);
}
logInfo(safeBody.userEmail, 'requestDeviceAccess2 authorized with openid session');
return res.status(200).json({ authorizationStatus: 'AUTHORIZED' });
if (requiresAdminCheck) {
await sendDeviceRequestAdminEmail(safeBody.userEmail, bankIds.internalId);
logInfo(
safeBody.userEmail,
'requestDeviceAccess2 authorized with openid session (waiting for admin check)',
);
} else {
logInfo(safeBody.userEmail, 'requestDeviceAccess2 authorized with openid session');
}
return res.status(200).json({ authorizationStatus: nextAuthorizationStatus });
} else {
// RESEND EMAIL IF REQUEST IS STILL PENDING
if (
Expand Down
Loading
Loading