From 06e4abc2957cf5a7c2efd3b669fc06a4510837fd Mon Sep 17 00:00:00 2001 From: giregk Date: Wed, 29 Jul 2026 12:27:13 +0200 Subject: [PATCH 1/8] 12965 add passwordless config in db --- .../2026-07-28_14-11-47_sso_passwordless.js | 19 +++++++++++++++++++ src/api2/routes/bank/getBankConfig.ts | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 migrations/2026-07-28_14-11-47_sso_passwordless.js diff --git a/migrations/2026-07-28_14-11-47_sso_passwordless.js b/migrations/2026-07-28_14-11-47_sso_passwordless.js new file mode 100644 index 0000000..72d8b5f --- /dev/null +++ b/migrations/2026-07-28_14-11-47_sso_passwordless.js @@ -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`, + ); +}; diff --git a/src/api2/routes/bank/getBankConfig.ts b/src/api2/routes/bank/getBankConfig.ts index 20e604b..211deb6 100644 --- a/src/api2/routes/bank/getBankConfig.ts +++ b/src/api2/routes/bank/getBankConfig.ts @@ -16,8 +16,7 @@ export const getBankConfig = async (req: any, res: any): Promise => { 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), '[]' @@ -38,6 +37,7 @@ export const getBankConfig = async (req: any, res: any): Promise => { 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, + usesSsoV2: true, // todo: (temporary) compute depending on email }); } catch (e) { logError('getBankConfig', e); From 1464b6c06f7313094610e8f6584c6b9672ce3ed6 Mon Sep 17 00:00:00 2001 From: giregk Date: Wed, 29 Jul 2026 17:28:20 +0200 Subject: [PATCH 2/8] 1965 add uses passwordless unlock route --- .../authentication/usesPasswordlessUnlock.ts | 71 +++++++++++++++++++ src/api2/routes/bank/getBankConfig.ts | 15 ++-- src/server.ts | 5 ++ 3 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 src/api2/routes/authentication/usesPasswordlessUnlock.ts diff --git a/src/api2/routes/authentication/usesPasswordlessUnlock.ts b/src/api2/routes/authentication/usesPasswordlessUnlock.ts new file mode 100644 index 0000000..6c2a790 --- /dev/null +++ b/src/api2/routes/authentication/usesPasswordlessUnlock.ts @@ -0,0 +1,71 @@ +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 => { + 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 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`, + [bankIds.internalId], + ); + if (bankRes.rowCount === 0) { + logInfo(email, 'usesPasswordlessUnlock fail: bad bank'); + res.status(400).end(); + return; + } + + const userRes = await db.query( + `SELECT deactivated, ms_entra_id FROM users WHERE email = $1 AND bank_id = $2`, + [email, bankIds.internalId], + ); + if ((userRes.rowCount ?? 0) === 0 && userRes.rows[0].deactivated) { + logInfo(email, 'usesPasswordlessUnlock fail: email not found or deactivated'); + res.status(400).end(); + return; + } + + 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`, + [bankIds.internalId, email], + ); + const patternPasswordless = (patternRes.rowCount ?? 0) > 0; + const usesPasswordlessUnlock = msEntraPasswordless || patternPasswordless; + + res.status(200).json({ + usesPasswordlessUnlock, + }); + return; + } catch (e) { + logError('usesPasswordlessUnlock', e); + res.status(400).end(); + return; + } +}; diff --git a/src/api2/routes/bank/getBankConfig.ts b/src/api2/routes/bank/getBankConfig.ts index 211deb6..224585c 100644 --- a/src/api2/routes/bank/getBankConfig.ts +++ b/src/api2/routes/bank/getBankConfig.ts @@ -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 => { +export const getBankConfig = async (req: Request, res: Response): Promise => { try { const bankIds = await getBankIds(req); const bankRes = await db.query( @@ -29,18 +28,20 @@ export const getBankConfig = async (req: any, res: any): Promise => { ); 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, - usesSsoV2: true, // todo: (temporary) compute depending on email }); + return; } catch (e) { logError('getBankConfig', e); - return res.status(400).end(); + res.status(400).end(); + return; } }; diff --git a/src/server.ts b/src/server.ts index 90d1b3b..572eb0f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -72,6 +72,7 @@ import { shamirSecurityAlert } from './api2/routes/shamirRecovery/shamirSecurity import { getShamirRecoveryChallenge } from './api2/routes/shamirRecovery/getShamirRecoveryChallenge'; import { getRecoveryKeyPair } from './api2/routes/shamirRecovery/getRecoveryKeyPair'; import helmet from 'helmet'; +import { usesPasswordlessUnlock } from './api2/routes/authentication/usesPasswordlessUnlock'; export const app = express(); @@ -169,6 +170,10 @@ app.post( app.post(['/:bankUUID/api2/authenticate', '/api2/authenticate'], authenticate2); app.post(['/:bankUUID/api2/disconnect', '/api2/disconnect'], disconnect2); app.post(['/:bankUUID/api2/authenticate-device-only'], authenticateDeviceOnly); +app.post( + ['/:bankUUID/api2/uses-passwordless-unlock', '/api2/uses-passwordless-unlock'], + usesPasswordlessUnlock, +); // OPENID app.post(['/:bankUUID/api2/authenticate-with-openid-auth-code'], authenticateWithOpenidAuthCode); From e56f1bee16f727af4b50087f0f97814c15cc965f Mon Sep 17 00:00:00 2001 From: Gireg Date: Thu, 30 Jul 2026 10:22:42 +0200 Subject: [PATCH 3/8] 12968 pairing sso vaults --- ...10-00-00_add_password_backup_public_key.js | 11 + .../authentication/usesPasswordlessUnlock.ts | 88 ++++---- src/api2/routes/data/getVaultData.ts | 48 +++++ .../authorizeDeviceWithOpenId.ts | 197 ++++++++++++++++++ .../deviceAuthorization/rejectSsoDevice.ts | 41 ++++ .../requestDeviceAccess.ts | 194 +++++++---------- .../sendPasswordBackupPublicKey.ts | 57 +++++ .../routes/passwordReset/backupPassword.ts | 25 ++- .../routes/passwordReset/getPasswordBackup.ts | 31 +-- src/server.ts | 12 ++ 10 files changed, 525 insertions(+), 179 deletions(-) create mode 100644 migrations/2026-07-27_10-00-00_add_password_backup_public_key.js create mode 100644 src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts create mode 100644 src/api2/routes/deviceAuthorization/rejectSsoDevice.ts create mode 100644 src/api2/routes/deviceAuthorization/sendPasswordBackupPublicKey.ts diff --git a/migrations/2026-07-27_10-00-00_add_password_backup_public_key.js b/migrations/2026-07-27_10-00-00_add_password_backup_public_key.js new file mode 100644 index 0000000..6e030fb --- /dev/null +++ b/migrations/2026-07-27_10-00-00_add_password_backup_public_key.js @@ -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'); +}; diff --git a/src/api2/routes/authentication/usesPasswordlessUnlock.ts b/src/api2/routes/authentication/usesPasswordlessUnlock.ts index 6c2a790..cb166b9 100644 --- a/src/api2/routes/authentication/usesPasswordlessUnlock.ts +++ b/src/api2/routes/authentication/usesPasswordlessUnlock.ts @@ -15,38 +15,57 @@ export const usesPasswordlessUnlock = async (req: Request, res: Response): Promi } const email = joiRes.value.userEmail; const bankIds = await getBankIds(req); - const bankRes = await db.query( - `SELECT + const usesPasswordless = await usesPasswordlessUnlockForEmail(email, bankIds.internalId); + if (usesPasswordless === null) { + res.status(404).end(); + return; + } + + res.status(200).json({ + usesPasswordlessUnlock: usesPasswordless, + }); + return; + } catch (e) { + logError('usesPasswordlessUnlock', e); + res.status(400).end(); + return; + } +}; + +export const usesPasswordlessUnlockForEmail = async ( + email: string, + bankId: number, +): Promise => { + 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`, - [bankIds.internalId], - ); - if (bankRes.rowCount === 0) { - logInfo(email, 'usesPasswordlessUnlock fail: bad bank'); - res.status(400).end(); - return; - } + [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, bankIds.internalId], - ); - if ((userRes.rowCount ?? 0) === 0 && userRes.rows[0].deactivated) { - logInfo(email, 'usesPasswordlessUnlock fail: email not found or deactivated'); - res.status(400).end(); - return; - } + 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 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 + const patternRes = await db.query( + `SELECT 1 FROM allowed_emails WHERE bank_id = $1 AND uses_passwordless_auth = true AND ( @@ -54,18 +73,9 @@ export const usesPasswordlessUnlock = async (req: Request, res: Response): Promi (LEFT(pattern, 2) = '*@' AND SPLIT_PART($2, '@', 2) = SUBSTRING(pattern FROM 3)) ) LIMIT 1`, - [bankIds.internalId, email], - ); - const patternPasswordless = (patternRes.rowCount ?? 0) > 0; - const usesPasswordlessUnlock = msEntraPasswordless || patternPasswordless; - - res.status(200).json({ - usesPasswordlessUnlock, - }); - return; - } catch (e) { - logError('usesPasswordlessUnlock', e); - res.status(400).end(); - return; - } + [bankId, email], + ); + const patternPasswordless = (patternRes.rowCount ?? 0) > 0; + const usesPasswordlessUnlock = msEntraPasswordless || patternPasswordless; + return usesPasswordlessUnlock; }; diff --git a/src/api2/routes/data/getVaultData.ts b/src/api2/routes/data/getVaultData.ts index 815f7ce..c3ecbe7 100644 --- a/src/api2/routes/data/getVaultData.ts +++ b/src/api2/routes/data/getVaultData.ts @@ -126,6 +126,11 @@ export const getVaultData = async (req: any, res: any): Promise => { } 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, @@ -142,6 +147,7 @@ export const getVaultData = async (req: any, res: any): Promise => { 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, @@ -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( diff --git a/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts b/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts new file mode 100644 index 0000000..34a435f --- /dev/null +++ b/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts @@ -0,0 +1,197 @@ +import { db } from '../../../helpers/db'; +import { logError, logInfo } from '../../../helpers/logger'; +import { + BANK_SETTINGS, + USER_SETTINGS_OVERRIDE, +} from '../../../helpers/getDefaultSettingOrUserOverride'; +import { isAllowedOnPlatform } from '../../../helpers/isAllowedOnPlatform'; +import { getEmailAuthorizationStatus } from '../../helpers/emailAuthorization'; +import { getBankIds } from '../../helpers/bankUUID'; +import Joi from 'joi'; +import { SessionStore } from '../../../helpers/sessionStore'; +import { hasAvailableLicence } from '../../../helpers/licenceCheck'; +import { Request, Response } from 'express'; +import { usesPasswordlessUnlockForEmail } from '../authentication/usesPasswordlessUnlock'; +import { sendDeviceRequestAdminEmail } from '../../../emails/sendDeviceRequestEmail'; + +// Authorizes a device purely on the strength of a valid OpenID session (SSO), regardless of +// whether this vault's master password is known to the user (passwordless/SSO vaults) or not. +// The device's public key is only ever sent here, once SSO succeeded — never up front. +// +// Being AUTHORIZED here only proves this device's identity (SSO + a freshly generated signing +// key) — it does NOT by itself grant access to the vault's actual data: on a passwordless vault +// that still requires either another already-authorized device to push a master password backup +// (see backup-password / getPendingSsoDevices) or a completed Shamir emergency recovery. +// +// If the bank has REQUIRE_ADMIN_CHECK_FOR_SECOND_DEVICE enabled and this is not the user's first +// device (see checkDevice2 for the equivalent email-flow gate), the device is left in +// USER_VERIFIED_PENDING_ADMIN_CHECK instead of AUTHORIZED. In that case its password backup +// public key is NOT stored: on a passwordless vault, a peer must never be able to discover and +// approve this device (see getPendingSsoDevices) before an admin has validated it. +export const authorizeDeviceWithOpenId = async (req: Request, res: Response) => { + try { + const bankIds = await getBankIds(req); + + const joiRes = Joi.object({ + userEmail: Joi.string().email().lowercase().required(), + deviceId: Joi.string().required(), + devicePublicKey: Joi.string().required(), + deviceName: Joi.string().required(), + deviceType: Joi.string(), + osFamily: Joi.string().required(), + osNameAndVersion: Joi.string().required(), + installType: Joi.string().required(), + appVersion: Joi.string().required(), + openidSession: Joi.string().required(), + devicePasswordBackupPublicKey: Joi.string(), + }).validate(req.body); + + if (joiRes.error) { + return res.status(400).json({ error: joiRes.error.details }); + } + const safeBody = joiRes.value; + + const isOpenidSessionOK = await SessionStore.checkOpenIdSession(safeBody.openidSession, { + userEmail: safeBody.userEmail, + bankId: bankIds.internalId, + }); + if (!isOpenidSessionOK) { + logInfo(safeBody.userEmail, 'authorizeDeviceWithOpenId fail: invalid openidSession'); + return res.status(401).end(); + } + + let userRes = await db.query( + `SELECT + users.id AS id, + users.deactivated AS deactivated, + users.settings_override AS settings_override, + users.encrypted_data_2 AS encrypted_data_2, + banks.settings AS bank_settings + FROM users INNER JOIN banks ON banks.id = users.bank_id + WHERE users.email=$1 AND users.bank_id=$2`, + [safeBody.userEmail, bankIds.internalId], + ); + // whether this user's vault already has data (ie. this is not the first, vault-creation + // device) — only an additional device on an already-created passwordless vault needs its + // backup public key tracked, so a peer can discover and approve it. + const hasVaultData = userRes.rowCount !== 0 && !!userRes.rows[0].encrypted_data_2; + if (userRes.rows[0]?.deactivated) { + return res.status(403).json({ error: 'user_deactivated' }); + } + if (userRes.rowCount === 0) { + const emailAuthStatusResponse = await getEmailAuthorizationStatus( + safeBody.userEmail, + bankIds.internalId, + ); + if (emailAuthStatusResponse.status === 'UNAUTHORIZED') { + logInfo(safeBody.userEmail, 'authorizeDeviceWithOpenId fail: email address not allowed'); + return res.status(403).json({ error: 'email_address_not_allowed' }); + } + if (!(await hasAvailableLicence(bankIds.internalId))) { + return res.status(403).json({ error: 'no_more_licence' }); + } + userRes = await db.query( + 'INSERT INTO users (email, ms_entra_id, bank_id) VALUES ($1,$2,$3) RETURNING id', + [safeBody.userEmail, emailAuthStatusResponse.msEntraId, bankIds.internalId], + ); + } + const userId = userRes.rows[0].id; + + const deviceRes = await db.query( + 'SELECT id FROM user_devices WHERE user_id=$1 AND device_unique_id=$2 AND bank_id=$3', + [userId, safeBody.deviceId, bankIds.internalId], + ); + const deviceInDb = deviceRes.rows[0]; + + // Same admin-check gate as the email-based flow (see checkDevice2): if this bank requires it + // and the user already has at least one other active device, this device is left pending + // instead of being authorized outright. + 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; + const nextAuthorizationStatus = requiresAdminCheck + ? 'USER_VERIFIED_PENDING_ADMIN_CHECK' + : 'AUTHORIZED'; + + // Only an additional device on an already-created passwordless vault needs its backup + // public key stored, so a peer can discover it via getPendingSsoDevices and push it a master + // password backup. On a non-passwordless vault, for the very first (vault-creation) device, + // or while still pending an admin check, there is no such need (or it would be unsafe). + const usesPasswordless = await usesPasswordlessUnlockForEmail( + safeBody.userEmail, + bankIds.internalId, + ); + const passwordBackupPublicKeyToStore = + hasVaultData && usesPasswordless && nextAuthorizationStatus === 'AUTHORIZED' + ? safeBody.devicePasswordBackupPublicKey + : null; + + if (!deviceInDb) { + const userAllowedOnPlatform = isAllowedOnPlatform( + safeBody.osFamily + safeBody.osNameAndVersion + safeBody.deviceType, + userRes.rows[0].bank_settings as BANK_SETTINGS, + userRes.rows[0].settings_override as USER_SETTINGS_OVERRIDE, + ); + if (!userAllowedOnPlatform) { + logInfo( + safeBody.userEmail, + `authorizeDeviceWithOpenId KO (not allowed on platform ${safeBody.osFamily})`, + ); + return res.status(403).json({ error: 'os_not_allowed' }); + } + await db.query( + "INSERT INTO user_devices (user_id, device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, bank_id, enrollment_method, password_backup_public_key) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'SSO',$12)", + [ + userId, + safeBody.deviceName, + safeBody.deviceType, + safeBody.installType, + safeBody.osFamily, + safeBody.osNameAndVersion, + safeBody.appVersion, + safeBody.deviceId, + safeBody.devicePublicKey, + nextAuthorizationStatus, + bankIds.internalId, + passwordBackupPublicKeyToStore, + ], + ); + } else { + await db.query( + "UPDATE user_devices SET (device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, enrollment_method, password_backup_public_key) = ($1,$2,$3,$4,$5,$6,$7,$8,$9,'SSO',$10) WHERE id=$11", + [ + safeBody.deviceName, + safeBody.deviceType, + safeBody.installType, + safeBody.osFamily, + safeBody.osNameAndVersion, + safeBody.appVersion, + safeBody.deviceId, + safeBody.devicePublicKey, + nextAuthorizationStatus, + passwordBackupPublicKeyToStore, + deviceInDb.id, + ], + ); + } + + if (requiresAdminCheck) { + await sendDeviceRequestAdminEmail(safeBody.userEmail, bankIds.internalId); + logInfo(safeBody.userEmail, 'authorizeDeviceWithOpenId OK (waiting for admin check)'); + } else { + logInfo(safeBody.userEmail, 'authorizeDeviceWithOpenId OK'); + } + return res.status(200).json({ authorizationStatus: nextAuthorizationStatus }); + } catch (e) { + logError(req.body?.userEmail, 'authorizeDeviceWithOpenId', e); + return res.status(400).end(); + } +}; diff --git a/src/api2/routes/deviceAuthorization/rejectSsoDevice.ts b/src/api2/routes/deviceAuthorization/rejectSsoDevice.ts new file mode 100644 index 0000000..3dccbaf --- /dev/null +++ b/src/api2/routes/deviceAuthorization/rejectSsoDevice.ts @@ -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(); + } +}; diff --git a/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts b/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts index 28e326b..f5cb7cc 100644 --- a/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts +++ b/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts @@ -11,10 +11,13 @@ import { isAllowedOnPlatform } from '../../../helpers/isAllowedOnPlatform'; import { getEmailAuthorizationStatus } from '../../helpers/emailAuthorization'; import { getBankIds } from '../../helpers/bankUUID'; import Joi from 'joi'; -import { SessionStore } from '../../../helpers/sessionStore'; import { hasAvailableLicence } from '../../../helpers/licenceCheck'; import { Request, Response } from 'express'; +// This route only ever handles the classic email-validation-code enrollment flow. SSO +// enrollment is handled by its own dedicated route (authorizeDeviceWithOpenId), which never +// goes through here. + // TESTS // - if I request access for a user that does not exist, it creates the user and the device request // - if I request access for an existing user but a new device, it creates the device request @@ -45,7 +48,6 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => { osNameAndVersion: Joi.string().required(), installType: Joi.string().required(), appVersion: Joi.string().required(), - openidSession: Joi.string(), }).validate(req.body); if (joiRes.error) { @@ -59,11 +61,14 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => { users.id AS id, users.deactivated AS deactivated, users.settings_override AS settings_override, + users.encrypted_data_2 AS encrypted_data_2, banks.settings AS bank_settings FROM users INNER JOIN banks ON banks.id = users.bank_id WHERE users.email=$1 AND users.bank_id=$2`, [safeBody.userEmail, bankIds.internalId], ); + // whether this user's vault already has data (ie. this is not the first, vault-creation device) + const hasVaultData = userRes.rowCount !== 0 && !!userRes.rows[0].encrypted_data_2; if (userRes.rows[0]?.deactivated) { return res.status(403).json({ error: 'user_deactivated' }); } @@ -93,7 +98,7 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => { // CHECK SECOND REQUESTS FOR SAME DEVICE const deviceRes = await db.query( - 'SELECT id, authorization_status, authorization_code, auth_code_expiration_date FROM user_devices WHERE user_id=$1 AND device_unique_id=$2 AND bank_id=$3', + 'SELECT id, authorization_status, authorization_code, auth_code_expiration_date, enrollment_method FROM user_devices WHERE user_id=$1 AND device_unique_id=$2 AND bank_id=$3', [userId, safeBody.deviceId, bankIds.internalId], ); const deviceInDb = deviceRes.rows[0]; @@ -118,133 +123,80 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => { } } - // if using openid session, validate it - let isOpenidAuthenticated = false; - if (safeBody.openidSession) { - const isOpenidSessionOK = await SessionStore.checkOpenIdSession(safeBody.openidSession, { - userEmail: safeBody.userEmail, - bankId: bankIds.internalId, - }); - if (!isOpenidSessionOK) { - logInfo(safeBody.userEmail, 'requestDeviceAccess2 fail: invalid openidSession'); - return res.status(401).end(); - } - isOpenidAuthenticated = true; - } - - if (isOpenidAuthenticated) { - if (!deviceInDb) { - // CREATE AUTHORIZED DEVICE - await db.query( - "INSERT INTO user_devices (user_id, device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, bank_id, enrollment_method) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'SSO')", - [ - userId, - safeBody.deviceName, - safeBody.deviceType, - safeBody.installType, - safeBody.osFamily, - safeBody.osNameAndVersion, - safeBody.appVersion, - safeBody.deviceId, - safeBody.devicePublicKey, - 'AUTHORIZED', - bankIds.internalId, - ], - ); - } else { - // AUTHORIZE EXISTING DEVICE (previously added with email method) - await db.query( - "UPDATE user_devices SET (device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, authorization_status, enrollment_method) = ($1,$2,$3,$4,$5,$6,$7,$8,'SSO') WHERE id=$9", - [ - safeBody.deviceName, - safeBody.deviceType, - safeBody.installType, - safeBody.osFamily, - safeBody.osNameAndVersion, - safeBody.appVersion, - safeBody.deviceId, - 'AUTHORIZED', - deviceInDb.id, - ], - ); - } - logInfo(safeBody.userEmail, 'requestDeviceAccess2 authorized with openid session'); - return res.status(200).json({ authorizationStatus: 'AUTHORIZED' }); - } else { - // RESEND EMAIL IF REQUEST IS STILL PENDING - if ( - deviceInDb && - deviceInDb.authorization_status === 'PENDING' && - !isExpired(deviceInDb.auth_code_expiration_date) - ) { - // resend email - await sendDeviceRequestEmail( - safeBody.userEmail, - safeBody.deviceName, - safeBody.deviceType, - safeBody.osNameAndVersion, - deviceInDb.authorization_code, - deviceInDb.auth_code_expiration_date, - acceptLanguage, - ); - logInfo(safeBody.userEmail, 'requestDeviceAccess2 OK (email resent)'); - return res.status(200).json({ authorizationStatus: 'PENDING' }); - } - - // ELSE UPDATE OR CREATE DEVICE - const randomAuthorizationCode = getRandomString(8); - const expirationDate = getExpirationDate(); - const nextDeviceStatus = 'PENDING'; - if (!deviceInDb) { - await db.query( - "INSERT INTO user_devices (user_id, device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, authorization_code, auth_code_expiration_date, bank_id, enrollment_method) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'email')", - [ - userId, - safeBody.deviceName, - safeBody.deviceType, - safeBody.installType, - safeBody.osFamily, - safeBody.osNameAndVersion, - safeBody.appVersion, - safeBody.deviceId, - safeBody.devicePublicKey, - nextDeviceStatus, - randomAuthorizationCode, - expirationDate.toISOString(), - bankIds.internalId, - ], - ); - } else { - // request is pending and expired, let's update it - await db.query( - "UPDATE user_devices SET (device_name, authorization_status, authorization_code, auth_code_expiration_date, enrollment_method) = ($1,$2,$3,$4,'email') WHERE user_id=$5 AND device_unique_id=$6 AND bank_id=$7", - [ - safeBody.deviceName, - nextDeviceStatus, - randomAuthorizationCode, - expirationDate, - userId, - safeBody.deviceId, - bankIds.internalId, - ], - ); - } - - // THEN SEND EMAIL + // RESEND EMAIL IF REQUEST IS STILL PENDING + if ( + deviceInDb && + deviceInDb.authorization_status === 'PENDING' && + !isExpired(deviceInDb.auth_code_expiration_date) + ) { + // resend email await sendDeviceRequestEmail( safeBody.userEmail, safeBody.deviceName, safeBody.deviceType, safeBody.osNameAndVersion, - randomAuthorizationCode, - expirationDate, + deviceInDb.authorization_code, + deviceInDb.auth_code_expiration_date, acceptLanguage, ); + logInfo(safeBody.userEmail, 'requestDeviceAccess2 OK (email resent)'); + return res.status(200).json({ authorizationStatus: 'PENDING' }); + } - logInfo(safeBody.userEmail, 'requestDeviceAccess2 OK (email sent with new code)'); - // Return res - return res.status(200).json({ authorizationStatus: 'MAIL_SENT' }); + // ELSE UPDATE OR CREATE DEVICE + const randomAuthorizationCode = getRandomString(8); + const expirationDate = getExpirationDate(); + const nextDeviceStatus = 'PENDING'; + + if (!deviceInDb) { + await db.query( + "INSERT INTO user_devices (user_id, device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, authorization_code, auth_code_expiration_date, bank_id, enrollment_method) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'email')", + [ + userId, + safeBody.deviceName, + safeBody.deviceType, + safeBody.installType, + safeBody.osFamily, + safeBody.osNameAndVersion, + safeBody.appVersion, + safeBody.deviceId, + safeBody.devicePublicKey, + nextDeviceStatus, + randomAuthorizationCode, + expirationDate.toISOString(), + bankIds.internalId, + ], + ); + } else { + // request is pending and expired, let's update it + await db.query( + "UPDATE user_devices SET (device_name, authorization_status, authorization_code, auth_code_expiration_date, enrollment_method) = ($1,$2,$3,$4,'email',$5) WHERE user_id=$6 AND device_unique_id=$7 AND bank_id=$8", + [ + safeBody.deviceName, + nextDeviceStatus, + randomAuthorizationCode, + expirationDate, + userId, + safeBody.deviceId, + bankIds.internalId, + ], + ); } + + // THEN SEND EMAIL + await sendDeviceRequestEmail( + safeBody.userEmail, + safeBody.deviceName, + safeBody.deviceType, + safeBody.osNameAndVersion, + randomAuthorizationCode, + expirationDate, + acceptLanguage, + ); + + logInfo(safeBody.userEmail, 'requestDeviceAccess2 OK (email sent with new code)'); + // Return res + return res.status(200).json({ authorizationStatus: 'MAIL_SENT' }); } catch (e) { logError(req.body?.userEmail, 'requestDeviceAccess', e); res.status(400).end(); diff --git a/src/api2/routes/deviceAuthorization/sendPasswordBackupPublicKey.ts b/src/api2/routes/deviceAuthorization/sendPasswordBackupPublicKey.ts new file mode 100644 index 0000000..4cd3b8e --- /dev/null +++ b/src/api2/routes/deviceAuthorization/sendPasswordBackupPublicKey.ts @@ -0,0 +1,57 @@ +import { Request, Response } from 'express'; +import Joi from 'joi'; +import { db } from '../../../helpers/db'; +import { logError, logInfo } from '../../../helpers/logger'; +import { checkDeviceAuth } from '../../helpers/authorizationChecks'; +import { usesPasswordlessUnlockForEmail } from '../authentication/usesPasswordlessUnlock'; + +// Lets an already-AUTHORIZED device (re)send its password backup public key once proven +// authentic via a deviceOnlySession (see authenticateDeviceOnly / checkDeviceAuth). This covers +// a device whose SSO enrollment was gated by an admin check (see authorizeDeviceWithOpenId): +// its key is withheld until the admin validates it, so it must be pushed here afterwards for a +// peer to be able to discover and approve it (see getPendingSsoDevices). +export const sendPasswordBackupPublicKey = async (req: Request, res: Response): Promise => { + try { + const deviceAuth = await checkDeviceAuth(req); + if (!deviceAuth.granted) { + logInfo(req.body?.userEmail, 'sendPasswordBackupPublicKey fail: device auth not granted'); + res.status(401).json({ error: 'badDeviceSession' }); + return; + } + + const joiRes = Joi.object({ + devicePasswordBackupPublicKey: Joi.string().required(), + }) + .unknown(true) + .validate(req.body); + if (joiRes.error) { + res.status(400).json({ error: joiRes.error.details }); + return; + } + const { devicePasswordBackupPublicKey } = joiRes.value; + + const usesPasswordless = await usesPasswordlessUnlockForEmail( + deviceAuth.vaultEmail, + deviceAuth.bankIds.internalId, + ); + if (!usesPasswordless) { + logInfo(req.body?.userEmail, 'sendPasswordBackupPublicKey OK (not passwordless, ignored)'); + res.status(200).end(); + return; + } + + // No-op if a key is already stored or a backup was already approved for this device: this + // only ever fills in a key that was withheld while pending an admin check. + await db.query( + `UPDATE user_devices SET password_backup_public_key=$1 + WHERE id=$2 AND authorization_status='AUTHORIZED' AND password_backup_public_key IS NULL + AND (encrypted_password_backup_2 IS NULL OR encrypted_password_backup_2 = '')`, + [devicePasswordBackupPublicKey, deviceAuth.deviceId], + ); + logInfo(req.body?.userEmail, 'sendPasswordBackupPublicKey OK'); + res.status(200).end(); + } catch (e) { + logError(req.body?.userEmail, 'sendPasswordBackupPublicKey', e); + res.status(400).end(); + } +}; diff --git a/src/api2/routes/passwordReset/backupPassword.ts b/src/api2/routes/passwordReset/backupPassword.ts index 707d6fd..cf95207 100644 --- a/src/api2/routes/passwordReset/backupPassword.ts +++ b/src/api2/routes/passwordReset/backupPassword.ts @@ -18,22 +18,33 @@ export const backupPassword2 = async (req: any, res: any) => { return res.status(401).end(); } - await Promise.all( - backups.map((backup) => - db.query( - "UPDATE user_devices SET encrypted_password_backup_2=$1 WHERE device_unique_id=$2 AND user_id=$3 AND authorization_status='AUTHORIZED' AND bank_id=$4", + const results = await Promise.all( + backups.map(async (backup) => { + // Also clears the target device's pending public key: receiving a backup here is the + // proof that another authorized device just approved it (see getPendingSsoDevices / + // authorizeSsoDevice). The device is already AUTHORIZED by this point (either via SSO's + // dedicated authorizeDeviceWithOpenId route, or via the classic email validation code) — + // this route never authorizes a device by itself. + const result = await db.query( + `UPDATE user_devices SET encrypted_password_backup_2=$1, password_backup_public_key=NULL + WHERE device_unique_id=$2 AND user_id=$3 AND bank_id=$4 AND authorization_status='AUTHORIZED'`, [ backup.encryptedPassword, backup.deviceId, basicAuth.userId, basicAuth.bankIds.internalId, ], - ), - ), + ); + return { deviceId: backup.deviceId, applied: (result.rowCount ?? 0) > 0 }; + }), ); + // A backup can fail to apply if the target device was concurrently revoked/rejected by + // another authorized device in the meantime (see rejectSsoDevice) — the caller needs to know + // which ones, so it doesn't record a device it never actually managed to back up. + const unappliedDeviceIds = results.filter((r) => !r.applied).map((r) => r.deviceId); logInfo(req.body?.userEmail, 'backupPassword2 OK'); // Return res - return res.status(204).end(); + return res.status(200).json({ unappliedDeviceIds }); } catch (e) { logError(req.body?.userEmail, 'backupPassword2', e); return res.status(400).end(); diff --git a/src/api2/routes/passwordReset/getPasswordBackup.ts b/src/api2/routes/passwordReset/getPasswordBackup.ts index 93ea3c8..a6dda80 100644 --- a/src/api2/routes/passwordReset/getPasswordBackup.ts +++ b/src/api2/routes/passwordReset/getPasswordBackup.ts @@ -9,6 +9,7 @@ import libsodium from 'libsodium-wrappers'; import { getBankIds } from '../../helpers/bankUUID'; import Joi from 'joi'; import { SessionStore } from '../../../helpers/sessionStore'; +import { usesPasswordlessUnlockForEmail } from '../authentication/usesPasswordlessUnlock'; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any export const getPasswordBackup2 = async (req: any, res: any) => { @@ -133,19 +134,25 @@ export const getPasswordBackup2 = async (req: any, res: any) => { return res.status(403).json({ error: 'backup_not_setup' }); } - if (safeBody.openidSession) { - // create password reset request for the record - await db.query( - `INSERT INTO password_reset_request (device_id, status, bank_id, granted_by) + const isPasswordlessSSOUnlock = await usesPasswordlessUnlockForEmail( + safeBody.userEmail, + bankIds.internalId, + ); + if (!isPasswordlessSSOUnlock) { + if (safeBody.openidSession) { + // create password reset request for the record + await db.query( + `INSERT INTO password_reset_request (device_id, status, bank_id, granted_by) VALUES ($1,'COMPLETED',$2, 'SSO authentication')`, - [deviceRes.rows[0].id, bankIds.internalId], - ); - } else { - // update status for reset request - await db.query( - `UPDATE password_reset_request SET status='COMPLETED', reset_token=null WHERE id=$1 AND bank_id=$2`, - [resetRequest.reset_request_id, bankIds.internalId], - ); + [deviceRes.rows[0].id, bankIds.internalId], + ); + } else { + // update status for reset request + await db.query( + `UPDATE password_reset_request SET status='COMPLETED', reset_token=null WHERE id=$1 AND bank_id=$2`, + [resetRequest.reset_request_id, bankIds.internalId], + ); + } } await db.query( 'UPDATE user_devices SET password_challenge_error_count=0, last_password_challenge_submission_date=null WHERE device_unique_id=$1 AND bank_id=$2', diff --git a/src/server.ts b/src/server.ts index 572eb0f..8ebfda9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,7 +15,10 @@ import { runMigrations } from './helpers/runMigrations'; import { getBankConfig } from './api2/routes/bank/getBankConfig'; import { getUrlList2 } from './api2/routes/bank/getUrlList'; import { requestDeviceAccess2 } from './api2/routes/deviceAuthorization/requestDeviceAccess'; +import { authorizeDeviceWithOpenId } from './api2/routes/deviceAuthorization/authorizeDeviceWithOpenId'; import { checkDevice2 } from './api2/routes/deviceAuthorization/checkDevice'; +import { rejectSsoDevice } from './api2/routes/deviceAuthorization/rejectSsoDevice'; +import { sendPasswordBackupPublicKey } from './api2/routes/deviceAuthorization/sendPasswordBackupPublicKey'; import { getAuthenticationChallenges2 } from './api2/routes/authentication/getAuthenticationChallenges'; import { authenticate2 } from './api2/routes/authentication/authenticate'; import { updateVaultData } from './api2/routes/data/updateVaultData'; @@ -161,6 +164,15 @@ app.post( requestDeviceAccess2, ); app.post(['/:bankUUID/api2/check-device', '/api2/check-device'], checkDevice2); +app.post(['/:bankUUID/api2/reject-sso-device', '/api2/reject-sso-device'], rejectSsoDevice); +app.post( + ['/:bankUUID/api2/authorize-device-with-openid', '/api2/authorize-device-with-openid'], + authorizeDeviceWithOpenId, +); +app.post( + ['/:bankUUID/api2/send-password-backup-public-key', '/api2/send-password-backup-public-key'], + sendPasswordBackupPublicKey, +); // AUTHENTICATION app.post( From 18fb53d495dc1b3ebb12ac1218d1cf6485d8e61d Mon Sep 17 00:00:00 2001 From: Gireg Date: Fri, 31 Jul 2026 11:12:21 +0200 Subject: [PATCH 4/8] 12968 add bypass sso system --- .../authenticateWithOpenidAuthCode.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/api2/routes/authentication/authenticateWithOpenidAuthCode.ts b/src/api2/routes/authentication/authenticateWithOpenidAuthCode.ts index 3346ddc..07d7f39 100644 --- a/src/api2/routes/authentication/authenticateWithOpenidAuthCode.ts +++ b/src/api2/routes/authentication/authenticateWithOpenidAuthCode.ts @@ -11,6 +11,10 @@ import { SessionStore } from '../../../helpers/sessionStore'; import { Request, Response } from 'express'; import { proxiedFetch } from '../../../helpers/xmlHttpRequest'; +// DO NOT COMMIT A REAL EMAIL ADDRESS HERE. +// This is a bypass code for debugging purposes, to be used with the "bypass_code" authCode. +const BYPASS_SSO_EMAIL: string | null = null; + export const authenticateWithOpenidAuthCode = async ( req: Request, res: Response, @@ -46,6 +50,26 @@ export const authenticateWithOpenidAuthCode = async ( res.status(400).end(); return; } + + if (BYPASS_SSO_EMAIL && safeBody.authCode === 'bypass_code') { + // bypass code for debugging purposes + const userEmail = BYPASS_SSO_EMAIL; + 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); From 07457ba698b3a188fad43a17fa5d4e605f2b6e51 Mon Sep 17 00:00:00 2001 From: Gireg Date: Fri, 31 Jul 2026 12:38:16 +0200 Subject: [PATCH 5/8] 12968 remove useless code --- .../authorizeDeviceWithOpenId.ts | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts b/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts index 34a435f..7df6af3 100644 --- a/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts +++ b/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts @@ -43,7 +43,6 @@ export const authorizeDeviceWithOpenId = async (req: Request, res: Response) => installType: Joi.string().required(), appVersion: Joi.string().required(), openidSession: Joi.string().required(), - devicePasswordBackupPublicKey: Joi.string(), }).validate(req.body); if (joiRes.error) { @@ -113,27 +112,13 @@ export const authorizeDeviceWithOpenId = async (req: Request, res: Response) => authorization_status = 'USER_VERIFIED_PENDING_ADMIN_CHECK')`, [userId, bankIds.internalId, safeBody.deviceId], ); - const isAdditionalDevice = - Number.parseInt(otherActiveDevicesRes.rows[0].device_count, 10) >= 1; + 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; const nextAuthorizationStatus = requiresAdminCheck ? 'USER_VERIFIED_PENDING_ADMIN_CHECK' : 'AUTHORIZED'; - // Only an additional device on an already-created passwordless vault needs its backup - // public key stored, so a peer can discover it via getPendingSsoDevices and push it a master - // password backup. On a non-passwordless vault, for the very first (vault-creation) device, - // or while still pending an admin check, there is no such need (or it would be unsafe). - const usesPasswordless = await usesPasswordlessUnlockForEmail( - safeBody.userEmail, - bankIds.internalId, - ); - const passwordBackupPublicKeyToStore = - hasVaultData && usesPasswordless && nextAuthorizationStatus === 'AUTHORIZED' - ? safeBody.devicePasswordBackupPublicKey - : null; - if (!deviceInDb) { const userAllowedOnPlatform = isAllowedOnPlatform( safeBody.osFamily + safeBody.osNameAndVersion + safeBody.deviceType, @@ -148,7 +133,7 @@ export const authorizeDeviceWithOpenId = async (req: Request, res: Response) => return res.status(403).json({ error: 'os_not_allowed' }); } await db.query( - "INSERT INTO user_devices (user_id, device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, bank_id, enrollment_method, password_backup_public_key) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'SSO',$12)", + "INSERT INTO user_devices (user_id, device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, bank_id, enrollment_method) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'SSO')", [ userId, safeBody.deviceName, @@ -161,12 +146,11 @@ export const authorizeDeviceWithOpenId = async (req: Request, res: Response) => safeBody.devicePublicKey, nextAuthorizationStatus, bankIds.internalId, - passwordBackupPublicKeyToStore, ], ); } else { await db.query( - "UPDATE user_devices SET (device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, enrollment_method, password_backup_public_key) = ($1,$2,$3,$4,$5,$6,$7,$8,$9,'SSO',$10) WHERE id=$11", + "UPDATE user_devices SET (device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, enrollment_method) = ($1,$2,$3,$4,$5,$6,$7,$8,$9,'SSO') WHERE id=$10", [ safeBody.deviceName, safeBody.deviceType, @@ -177,7 +161,6 @@ export const authorizeDeviceWithOpenId = async (req: Request, res: Response) => safeBody.deviceId, safeBody.devicePublicKey, nextAuthorizationStatus, - passwordBackupPublicKeyToStore, deviceInDb.id, ], ); From 27cbf6d74c812bdede1301bce488e29ecb5c75b3 Mon Sep 17 00:00:00 2001 From: Gireg Date: Fri, 31 Jul 2026 14:28:53 +0200 Subject: [PATCH 6/8] 12968 fix issues --- .../authenticateWithOpenidAuthCode.ts | 39 +++++++++---------- .../authentication/usesPasswordlessUnlock.ts | 2 +- .../authorizeDeviceWithOpenId.ts | 1 - .../requestDeviceAccess.ts | 2 +- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/api2/routes/authentication/authenticateWithOpenidAuthCode.ts b/src/api2/routes/authentication/authenticateWithOpenidAuthCode.ts index 07d7f39..547606b 100644 --- a/src/api2/routes/authentication/authenticateWithOpenidAuthCode.ts +++ b/src/api2/routes/authentication/authenticateWithOpenidAuthCode.ts @@ -11,10 +11,6 @@ import { SessionStore } from '../../../helpers/sessionStore'; import { Request, Response } from 'express'; import { proxiedFetch } from '../../../helpers/xmlHttpRequest'; -// DO NOT COMMIT A REAL EMAIL ADDRESS HERE. -// This is a bypass code for debugging purposes, to be used with the "bypass_code" authCode. -const BYPASS_SSO_EMAIL: string | null = null; - export const authenticateWithOpenidAuthCode = async ( req: Request, res: Response, @@ -51,24 +47,25 @@ export const authenticateWithOpenidAuthCode = async ( return; } - if (BYPASS_SSO_EMAIL && safeBody.authCode === 'bypass_code') { - // bypass code for debugging purposes - const userEmail = BYPASS_SSO_EMAIL; - const openidSession = await SessionStore.createOpenIdSession( - { - bankId: bankIds.internalId, - accessToken: 'bypass_access_token', - userEmail, - }, - Date.now() + 3600 * 1000, - ); + // // 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; - } + // res.status(200).json({ + // openidSession, + // email: userEmail, + // }); + // return; + // } const clientId = bankConfigRes.rows[0]!.client_id; diff --git a/src/api2/routes/authentication/usesPasswordlessUnlock.ts b/src/api2/routes/authentication/usesPasswordlessUnlock.ts index cb166b9..f309d78 100644 --- a/src/api2/routes/authentication/usesPasswordlessUnlock.ts +++ b/src/api2/routes/authentication/usesPasswordlessUnlock.ts @@ -53,7 +53,7 @@ export const usesPasswordlessUnlockForEmail = async ( `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) { + if ((userRes.rowCount ?? 0) === 0 || userRes.rows[0].deactivated) { logInfo(email, 'usesPasswordlessUnlock fail: email not found or deactivated'); return null; } diff --git a/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts b/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts index 7df6af3..1a37184 100644 --- a/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts +++ b/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts @@ -11,7 +11,6 @@ import Joi from 'joi'; import { SessionStore } from '../../../helpers/sessionStore'; import { hasAvailableLicence } from '../../../helpers/licenceCheck'; import { Request, Response } from 'express'; -import { usesPasswordlessUnlockForEmail } from '../authentication/usesPasswordlessUnlock'; import { sendDeviceRequestAdminEmail } from '../../../emails/sendDeviceRequestEmail'; // Authorizes a device purely on the strength of a valid OpenID session (SSO), regardless of diff --git a/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts b/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts index f5cb7cc..2b057c4 100644 --- a/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts +++ b/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts @@ -170,7 +170,7 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => { } else { // request is pending and expired, let's update it await db.query( - "UPDATE user_devices SET (device_name, authorization_status, authorization_code, auth_code_expiration_date, enrollment_method) = ($1,$2,$3,$4,'email',$5) WHERE user_id=$6 AND device_unique_id=$7 AND bank_id=$8", + "UPDATE user_devices SET (device_name, authorization_status, authorization_code, auth_code_expiration_date, enrollment_method) = ($1,$2,$3,$4,'email') WHERE user_id=$5 AND device_unique_id=$6 AND bank_id=$7", [ safeBody.deviceName, nextDeviceStatus, From f2eff97f27870ee7caeb5abde6f8db28ca9cdc8e Mon Sep 17 00:00:00 2001 From: Gireg Date: Fri, 31 Jul 2026 14:48:54 +0200 Subject: [PATCH 7/8] 12968 clean code --- src/api2/routes/authentication/usesPasswordlessUnlock.ts | 6 ++++-- .../routes/deviceAuthorization/authorizeDeviceWithOpenId.ts | 5 ----- src/api2/routes/deviceAuthorization/requestDeviceAccess.ts | 3 --- src/api2/routes/passwordReset/backupPassword.ts | 6 +----- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/api2/routes/authentication/usesPasswordlessUnlock.ts b/src/api2/routes/authentication/usesPasswordlessUnlock.ts index f309d78..d1b4b84 100644 --- a/src/api2/routes/authentication/usesPasswordlessUnlock.ts +++ b/src/api2/routes/authentication/usesPasswordlessUnlock.ts @@ -17,7 +17,8 @@ export const usesPasswordlessUnlock = async (req: Request, res: Response): Promi const bankIds = await getBankIds(req); const usesPasswordless = await usesPasswordlessUnlockForEmail(email, bankIds.internalId); if (usesPasswordless === null) { - res.status(404).end(); + // return 200 to prevent account enumeration. + res.status(200).json({ usesPasswordlessUnlock: false }); return; } @@ -27,7 +28,8 @@ export const usesPasswordlessUnlock = async (req: Request, res: Response): Promi return; } catch (e) { logError('usesPasswordlessUnlock', e); - res.status(400).end(); + // return 200 to prevent account enumeration. + res.status(200).json({ usesPasswordlessUnlock: false }); return; } }; diff --git a/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts b/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts index 1a37184..5a384f3 100644 --- a/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts +++ b/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts @@ -63,16 +63,11 @@ export const authorizeDeviceWithOpenId = async (req: Request, res: Response) => users.id AS id, users.deactivated AS deactivated, users.settings_override AS settings_override, - users.encrypted_data_2 AS encrypted_data_2, banks.settings AS bank_settings FROM users INNER JOIN banks ON banks.id = users.bank_id WHERE users.email=$1 AND users.bank_id=$2`, [safeBody.userEmail, bankIds.internalId], ); - // whether this user's vault already has data (ie. this is not the first, vault-creation - // device) — only an additional device on an already-created passwordless vault needs its - // backup public key tracked, so a peer can discover and approve it. - const hasVaultData = userRes.rowCount !== 0 && !!userRes.rows[0].encrypted_data_2; if (userRes.rows[0]?.deactivated) { return res.status(403).json({ error: 'user_deactivated' }); } diff --git a/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts b/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts index 2b057c4..a1776f8 100644 --- a/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts +++ b/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts @@ -61,14 +61,11 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => { users.id AS id, users.deactivated AS deactivated, users.settings_override AS settings_override, - users.encrypted_data_2 AS encrypted_data_2, banks.settings AS bank_settings FROM users INNER JOIN banks ON banks.id = users.bank_id WHERE users.email=$1 AND users.bank_id=$2`, [safeBody.userEmail, bankIds.internalId], ); - // whether this user's vault already has data (ie. this is not the first, vault-creation device) - const hasVaultData = userRes.rowCount !== 0 && !!userRes.rows[0].encrypted_data_2; if (userRes.rows[0]?.deactivated) { return res.status(403).json({ error: 'user_deactivated' }); } diff --git a/src/api2/routes/passwordReset/backupPassword.ts b/src/api2/routes/passwordReset/backupPassword.ts index cf95207..0f8f391 100644 --- a/src/api2/routes/passwordReset/backupPassword.ts +++ b/src/api2/routes/passwordReset/backupPassword.ts @@ -20,11 +20,7 @@ export const backupPassword2 = async (req: any, res: any) => { const results = await Promise.all( backups.map(async (backup) => { - // Also clears the target device's pending public key: receiving a backup here is the - // proof that another authorized device just approved it (see getPendingSsoDevices / - // authorizeSsoDevice). The device is already AUTHORIZED by this point (either via SSO's - // dedicated authorizeDeviceWithOpenId route, or via the classic email validation code) — - // this route never authorizes a device by itself. + // Also clears the target device's pending public key (SSO device pairing flow). const result = await db.query( `UPDATE user_devices SET encrypted_password_backup_2=$1, password_backup_public_key=NULL WHERE device_unique_id=$2 AND user_id=$3 AND bank_id=$4 AND authorization_status='AUTHORIZED'`, From fdc90b844895711942a1a17cbf4799af8053259e Mon Sep 17 00:00:00 2001 From: Gireg Date: Fri, 31 Jul 2026 16:37:29 +0200 Subject: [PATCH 8/8] 12968 revert breaking api change --- .../authorizeDeviceWithOpenId.ts | 174 -------------- .../requestDeviceAccess.ts | 220 ++++++++++++------ .../sendPasswordBackupPublicKey.ts | 5 +- src/server.ts | 5 - 4 files changed, 150 insertions(+), 254 deletions(-) delete mode 100644 src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts diff --git a/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts b/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts deleted file mode 100644 index 5a384f3..0000000 --- a/src/api2/routes/deviceAuthorization/authorizeDeviceWithOpenId.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { db } from '../../../helpers/db'; -import { logError, logInfo } from '../../../helpers/logger'; -import { - BANK_SETTINGS, - USER_SETTINGS_OVERRIDE, -} from '../../../helpers/getDefaultSettingOrUserOverride'; -import { isAllowedOnPlatform } from '../../../helpers/isAllowedOnPlatform'; -import { getEmailAuthorizationStatus } from '../../helpers/emailAuthorization'; -import { getBankIds } from '../../helpers/bankUUID'; -import Joi from 'joi'; -import { SessionStore } from '../../../helpers/sessionStore'; -import { hasAvailableLicence } from '../../../helpers/licenceCheck'; -import { Request, Response } from 'express'; -import { sendDeviceRequestAdminEmail } from '../../../emails/sendDeviceRequestEmail'; - -// Authorizes a device purely on the strength of a valid OpenID session (SSO), regardless of -// whether this vault's master password is known to the user (passwordless/SSO vaults) or not. -// The device's public key is only ever sent here, once SSO succeeded — never up front. -// -// Being AUTHORIZED here only proves this device's identity (SSO + a freshly generated signing -// key) — it does NOT by itself grant access to the vault's actual data: on a passwordless vault -// that still requires either another already-authorized device to push a master password backup -// (see backup-password / getPendingSsoDevices) or a completed Shamir emergency recovery. -// -// If the bank has REQUIRE_ADMIN_CHECK_FOR_SECOND_DEVICE enabled and this is not the user's first -// device (see checkDevice2 for the equivalent email-flow gate), the device is left in -// USER_VERIFIED_PENDING_ADMIN_CHECK instead of AUTHORIZED. In that case its password backup -// public key is NOT stored: on a passwordless vault, a peer must never be able to discover and -// approve this device (see getPendingSsoDevices) before an admin has validated it. -export const authorizeDeviceWithOpenId = async (req: Request, res: Response) => { - try { - const bankIds = await getBankIds(req); - - const joiRes = Joi.object({ - userEmail: Joi.string().email().lowercase().required(), - deviceId: Joi.string().required(), - devicePublicKey: Joi.string().required(), - deviceName: Joi.string().required(), - deviceType: Joi.string(), - osFamily: Joi.string().required(), - osNameAndVersion: Joi.string().required(), - installType: Joi.string().required(), - appVersion: Joi.string().required(), - openidSession: Joi.string().required(), - }).validate(req.body); - - if (joiRes.error) { - return res.status(400).json({ error: joiRes.error.details }); - } - const safeBody = joiRes.value; - - const isOpenidSessionOK = await SessionStore.checkOpenIdSession(safeBody.openidSession, { - userEmail: safeBody.userEmail, - bankId: bankIds.internalId, - }); - if (!isOpenidSessionOK) { - logInfo(safeBody.userEmail, 'authorizeDeviceWithOpenId fail: invalid openidSession'); - return res.status(401).end(); - } - - let userRes = await db.query( - `SELECT - users.id AS id, - users.deactivated AS deactivated, - users.settings_override AS settings_override, - banks.settings AS bank_settings - FROM users INNER JOIN banks ON banks.id = users.bank_id - WHERE users.email=$1 AND users.bank_id=$2`, - [safeBody.userEmail, bankIds.internalId], - ); - if (userRes.rows[0]?.deactivated) { - return res.status(403).json({ error: 'user_deactivated' }); - } - if (userRes.rowCount === 0) { - const emailAuthStatusResponse = await getEmailAuthorizationStatus( - safeBody.userEmail, - bankIds.internalId, - ); - if (emailAuthStatusResponse.status === 'UNAUTHORIZED') { - logInfo(safeBody.userEmail, 'authorizeDeviceWithOpenId fail: email address not allowed'); - return res.status(403).json({ error: 'email_address_not_allowed' }); - } - if (!(await hasAvailableLicence(bankIds.internalId))) { - return res.status(403).json({ error: 'no_more_licence' }); - } - userRes = await db.query( - 'INSERT INTO users (email, ms_entra_id, bank_id) VALUES ($1,$2,$3) RETURNING id', - [safeBody.userEmail, emailAuthStatusResponse.msEntraId, bankIds.internalId], - ); - } - const userId = userRes.rows[0].id; - - const deviceRes = await db.query( - 'SELECT id FROM user_devices WHERE user_id=$1 AND device_unique_id=$2 AND bank_id=$3', - [userId, safeBody.deviceId, bankIds.internalId], - ); - const deviceInDb = deviceRes.rows[0]; - - // Same admin-check gate as the email-based flow (see checkDevice2): if this bank requires it - // and the user already has at least one other active device, this device is left pending - // instead of being authorized outright. - 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; - const nextAuthorizationStatus = requiresAdminCheck - ? 'USER_VERIFIED_PENDING_ADMIN_CHECK' - : 'AUTHORIZED'; - - if (!deviceInDb) { - const userAllowedOnPlatform = isAllowedOnPlatform( - safeBody.osFamily + safeBody.osNameAndVersion + safeBody.deviceType, - userRes.rows[0].bank_settings as BANK_SETTINGS, - userRes.rows[0].settings_override as USER_SETTINGS_OVERRIDE, - ); - if (!userAllowedOnPlatform) { - logInfo( - safeBody.userEmail, - `authorizeDeviceWithOpenId KO (not allowed on platform ${safeBody.osFamily})`, - ); - return res.status(403).json({ error: 'os_not_allowed' }); - } - await db.query( - "INSERT INTO user_devices (user_id, device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, bank_id, enrollment_method) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'SSO')", - [ - userId, - safeBody.deviceName, - safeBody.deviceType, - safeBody.installType, - safeBody.osFamily, - safeBody.osNameAndVersion, - safeBody.appVersion, - safeBody.deviceId, - safeBody.devicePublicKey, - nextAuthorizationStatus, - bankIds.internalId, - ], - ); - } else { - await db.query( - "UPDATE user_devices SET (device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, enrollment_method) = ($1,$2,$3,$4,$5,$6,$7,$8,$9,'SSO') WHERE id=$10", - [ - safeBody.deviceName, - safeBody.deviceType, - safeBody.installType, - safeBody.osFamily, - safeBody.osNameAndVersion, - safeBody.appVersion, - safeBody.deviceId, - safeBody.devicePublicKey, - nextAuthorizationStatus, - deviceInDb.id, - ], - ); - } - - if (requiresAdminCheck) { - await sendDeviceRequestAdminEmail(safeBody.userEmail, bankIds.internalId); - logInfo(safeBody.userEmail, 'authorizeDeviceWithOpenId OK (waiting for admin check)'); - } else { - logInfo(safeBody.userEmail, 'authorizeDeviceWithOpenId OK'); - } - return res.status(200).json({ authorizationStatus: nextAuthorizationStatus }); - } catch (e) { - logError(req.body?.userEmail, 'authorizeDeviceWithOpenId', e); - return res.status(400).end(); - } -}; diff --git a/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts b/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts index a1776f8..03b0ef8 100644 --- a/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts +++ b/src/api2/routes/deviceAuthorization/requestDeviceAccess.ts @@ -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 { @@ -11,13 +14,10 @@ import { isAllowedOnPlatform } from '../../../helpers/isAllowedOnPlatform'; import { getEmailAuthorizationStatus } from '../../helpers/emailAuthorization'; import { getBankIds } from '../../helpers/bankUUID'; import Joi from 'joi'; +import { SessionStore } from '../../../helpers/sessionStore'; import { hasAvailableLicence } from '../../../helpers/licenceCheck'; import { Request, Response } from 'express'; -// This route only ever handles the classic email-validation-code enrollment flow. SSO -// enrollment is handled by its own dedicated route (authorizeDeviceWithOpenId), which never -// goes through here. - // TESTS // - if I request access for a user that does not exist, it creates the user and the device request // - if I request access for an existing user but a new device, it creates the device request @@ -48,6 +48,7 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => { osNameAndVersion: Joi.string().required(), installType: Joi.string().required(), appVersion: Joi.string().required(), + openidSession: Joi.string(), }).validate(req.body); if (joiRes.error) { @@ -95,7 +96,7 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => { // CHECK SECOND REQUESTS FOR SAME DEVICE const deviceRes = await db.query( - 'SELECT id, authorization_status, authorization_code, auth_code_expiration_date, enrollment_method FROM user_devices WHERE user_id=$1 AND device_unique_id=$2 AND bank_id=$3', + 'SELECT id, authorization_status, authorization_code, auth_code_expiration_date FROM user_devices WHERE user_id=$1 AND device_unique_id=$2 AND bank_id=$3', [userId, safeBody.deviceId, bankIds.internalId], ); const deviceInDb = deviceRes.rows[0]; @@ -120,80 +121,157 @@ export const requestDeviceAccess2 = async (req: Request, res: Response) => { } } - // RESEND EMAIL IF REQUEST IS STILL PENDING - if ( - deviceInDb && - deviceInDb.authorization_status === 'PENDING' && - !isExpired(deviceInDb.auth_code_expiration_date) - ) { - // resend email + // if using openid session, validate it + let isOpenidAuthenticated = false; + if (safeBody.openidSession) { + const isOpenidSessionOK = await SessionStore.checkOpenIdSession(safeBody.openidSession, { + userEmail: safeBody.userEmail, + bankId: bankIds.internalId, + }); + if (!isOpenidSessionOK) { + logInfo(safeBody.userEmail, 'requestDeviceAccess2 fail: invalid openidSession'); + return res.status(401).end(); + } + isOpenidAuthenticated = true; + } + + 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; + const nextAuthorizationStatus = requiresAdminCheck + ? 'USER_VERIFIED_PENDING_ADMIN_CHECK' + : 'AUTHORIZED'; + + if (!deviceInDb) { + // CREATE AUTHORIZED DEVICE + await db.query( + "INSERT INTO user_devices (user_id, device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, bank_id, enrollment_method) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'SSO')", + [ + userId, + safeBody.deviceName, + safeBody.deviceType, + safeBody.installType, + safeBody.osFamily, + safeBody.osNameAndVersion, + safeBody.appVersion, + safeBody.deviceId, + safeBody.devicePublicKey, + nextAuthorizationStatus, + bankIds.internalId, + ], + ); + } else { + // AUTHORIZE EXISTING DEVICE (previously added with email method) + await db.query( + "UPDATE user_devices SET (device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, authorization_status, enrollment_method) = ($1,$2,$3,$4,$5,$6,$7,$8,'SSO') WHERE id=$9", + [ + safeBody.deviceName, + safeBody.deviceType, + safeBody.installType, + safeBody.osFamily, + safeBody.osNameAndVersion, + safeBody.appVersion, + safeBody.deviceId, + nextAuthorizationStatus, + deviceInDb.id, + ], + ); + } + 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 ( + deviceInDb && + deviceInDb.authorization_status === 'PENDING' && + !isExpired(deviceInDb.auth_code_expiration_date) + ) { + // resend email + await sendDeviceRequestEmail( + safeBody.userEmail, + safeBody.deviceName, + safeBody.deviceType, + safeBody.osNameAndVersion, + deviceInDb.authorization_code, + deviceInDb.auth_code_expiration_date, + acceptLanguage, + ); + logInfo(safeBody.userEmail, 'requestDeviceAccess2 OK (email resent)'); + return res.status(200).json({ authorizationStatus: 'PENDING' }); + } + + // ELSE UPDATE OR CREATE DEVICE + const randomAuthorizationCode = getRandomString(8); + const expirationDate = getExpirationDate(); + const nextDeviceStatus = 'PENDING'; + if (!deviceInDb) { + await db.query( + "INSERT INTO user_devices (user_id, device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, authorization_code, auth_code_expiration_date, bank_id, enrollment_method) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'email')", + [ + userId, + safeBody.deviceName, + safeBody.deviceType, + safeBody.installType, + safeBody.osFamily, + safeBody.osNameAndVersion, + safeBody.appVersion, + safeBody.deviceId, + safeBody.devicePublicKey, + nextDeviceStatus, + randomAuthorizationCode, + expirationDate.toISOString(), + bankIds.internalId, + ], + ); + } else { + // request is pending and expired, let's update it + await db.query( + "UPDATE user_devices SET (device_name, authorization_status, authorization_code, auth_code_expiration_date, enrollment_method) = ($1,$2,$3,$4,'email') WHERE user_id=$5 AND device_unique_id=$6 AND bank_id=$7", + [ + safeBody.deviceName, + nextDeviceStatus, + randomAuthorizationCode, + expirationDate, + userId, + safeBody.deviceId, + bankIds.internalId, + ], + ); + } + + // THEN SEND EMAIL await sendDeviceRequestEmail( safeBody.userEmail, safeBody.deviceName, safeBody.deviceType, safeBody.osNameAndVersion, - deviceInDb.authorization_code, - deviceInDb.auth_code_expiration_date, + randomAuthorizationCode, + expirationDate, acceptLanguage, ); - logInfo(safeBody.userEmail, 'requestDeviceAccess2 OK (email resent)'); - return res.status(200).json({ authorizationStatus: 'PENDING' }); - } - - // ELSE UPDATE OR CREATE DEVICE - const randomAuthorizationCode = getRandomString(8); - const expirationDate = getExpirationDate(); - const nextDeviceStatus = 'PENDING'; - if (!deviceInDb) { - await db.query( - "INSERT INTO user_devices (user_id, device_name, device_type, install_type, os_family, os_version, app_version, device_unique_id, device_public_key_2, authorization_status, authorization_code, auth_code_expiration_date, bank_id, enrollment_method) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'email')", - [ - userId, - safeBody.deviceName, - safeBody.deviceType, - safeBody.installType, - safeBody.osFamily, - safeBody.osNameAndVersion, - safeBody.appVersion, - safeBody.deviceId, - safeBody.devicePublicKey, - nextDeviceStatus, - randomAuthorizationCode, - expirationDate.toISOString(), - bankIds.internalId, - ], - ); - } else { - // request is pending and expired, let's update it - await db.query( - "UPDATE user_devices SET (device_name, authorization_status, authorization_code, auth_code_expiration_date, enrollment_method) = ($1,$2,$3,$4,'email') WHERE user_id=$5 AND device_unique_id=$6 AND bank_id=$7", - [ - safeBody.deviceName, - nextDeviceStatus, - randomAuthorizationCode, - expirationDate, - userId, - safeBody.deviceId, - bankIds.internalId, - ], - ); + logInfo(safeBody.userEmail, 'requestDeviceAccess2 OK (email sent with new code)'); + // Return res + return res.status(200).json({ authorizationStatus: 'MAIL_SENT' }); } - - // THEN SEND EMAIL - await sendDeviceRequestEmail( - safeBody.userEmail, - safeBody.deviceName, - safeBody.deviceType, - safeBody.osNameAndVersion, - randomAuthorizationCode, - expirationDate, - acceptLanguage, - ); - - logInfo(safeBody.userEmail, 'requestDeviceAccess2 OK (email sent with new code)'); - // Return res - return res.status(200).json({ authorizationStatus: 'MAIL_SENT' }); } catch (e) { logError(req.body?.userEmail, 'requestDeviceAccess', e); res.status(400).end(); diff --git a/src/api2/routes/deviceAuthorization/sendPasswordBackupPublicKey.ts b/src/api2/routes/deviceAuthorization/sendPasswordBackupPublicKey.ts index 4cd3b8e..28724a5 100644 --- a/src/api2/routes/deviceAuthorization/sendPasswordBackupPublicKey.ts +++ b/src/api2/routes/deviceAuthorization/sendPasswordBackupPublicKey.ts @@ -6,10 +6,7 @@ import { checkDeviceAuth } from '../../helpers/authorizationChecks'; import { usesPasswordlessUnlockForEmail } from '../authentication/usesPasswordlessUnlock'; // Lets an already-AUTHORIZED device (re)send its password backup public key once proven -// authentic via a deviceOnlySession (see authenticateDeviceOnly / checkDeviceAuth). This covers -// a device whose SSO enrollment was gated by an admin check (see authorizeDeviceWithOpenId): -// its key is withheld until the admin validates it, so it must be pushed here afterwards for a -// peer to be able to discover and approve it (see getPendingSsoDevices). +// authentic via a deviceOnlySession. This is needed for SSO device pairing. export const sendPasswordBackupPublicKey = async (req: Request, res: Response): Promise => { try { const deviceAuth = await checkDeviceAuth(req); diff --git a/src/server.ts b/src/server.ts index 8ebfda9..ad631ff 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,7 +15,6 @@ import { runMigrations } from './helpers/runMigrations'; import { getBankConfig } from './api2/routes/bank/getBankConfig'; import { getUrlList2 } from './api2/routes/bank/getUrlList'; import { requestDeviceAccess2 } from './api2/routes/deviceAuthorization/requestDeviceAccess'; -import { authorizeDeviceWithOpenId } from './api2/routes/deviceAuthorization/authorizeDeviceWithOpenId'; import { checkDevice2 } from './api2/routes/deviceAuthorization/checkDevice'; import { rejectSsoDevice } from './api2/routes/deviceAuthorization/rejectSsoDevice'; import { sendPasswordBackupPublicKey } from './api2/routes/deviceAuthorization/sendPasswordBackupPublicKey'; @@ -165,10 +164,6 @@ app.post( ); app.post(['/:bankUUID/api2/check-device', '/api2/check-device'], checkDevice2); app.post(['/:bankUUID/api2/reject-sso-device', '/api2/reject-sso-device'], rejectSsoDevice); -app.post( - ['/:bankUUID/api2/authorize-device-with-openid', '/api2/authorize-device-with-openid'], - authorizeDeviceWithOpenId, -); app.post( ['/:bankUUID/api2/send-password-backup-public-key', '/api2/send-password-backup-public-key'], sendPasswordBackupPublicKey,