From 73ce67642ba2e2b25c161eabb5e9661a0f81fb93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 22 Jun 2026 22:54:50 +0300 Subject: [PATCH 1/7] Add mfa_required column to AuthGroups in v3.8.0 schema and seed data. System groups default to MFA off on install; custom groups inherit the column default. --- .../migrations/mysql/db_migration_mysql_v3.8.0.sql | 1 + .../migrations/postgres/db_migration_pg_v3.8.0.sql | 1 + .../migrations/sqlite/db_migration_sqlite_v3.8.0.sql | 1 + src/data/models/authGroup.js | 6 ++++++ src/data/seeders/mysql/db_seeder_mysql_v3.8.0.sql | 10 +++++----- src/data/seeders/postgres/db_seeder_pg_v3.8.0.sql | 10 +++++----- src/data/seeders/sqlite/db_seeder_sqlite_v3.8.0.sql | 10 +++++----- 7 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/data/migrations/mysql/db_migration_mysql_v3.8.0.sql b/src/data/migrations/mysql/db_migration_mysql_v3.8.0.sql index edd71845..9eea616f 100644 --- a/src/data/migrations/mysql/db_migration_mysql_v3.8.0.sql +++ b/src/data/migrations/mysql/db_migration_mysql_v3.8.0.sql @@ -991,6 +991,7 @@ CREATE TABLE IF NOT EXISTS AuthGroups ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, is_system BOOLEAN DEFAULT false, + mfa_required BOOLEAN NOT NULL DEFAULT false, created_at DATETIME, updated_at DATETIME ); diff --git a/src/data/migrations/postgres/db_migration_pg_v3.8.0.sql b/src/data/migrations/postgres/db_migration_pg_v3.8.0.sql index 5bc338b5..1c569600 100644 --- a/src/data/migrations/postgres/db_migration_pg_v3.8.0.sql +++ b/src/data/migrations/postgres/db_migration_pg_v3.8.0.sql @@ -983,6 +983,7 @@ CREATE TABLE IF NOT EXISTS "AuthGroups" ( id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY NOT NULL, name VARCHAR(255) NOT NULL UNIQUE, is_system BOOLEAN DEFAULT false, + mfa_required BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP(0), updated_at TIMESTAMP(0) ); diff --git a/src/data/migrations/sqlite/db_migration_sqlite_v3.8.0.sql b/src/data/migrations/sqlite/db_migration_sqlite_v3.8.0.sql index 0e88cbcb..5947f5c4 100644 --- a/src/data/migrations/sqlite/db_migration_sqlite_v3.8.0.sql +++ b/src/data/migrations/sqlite/db_migration_sqlite_v3.8.0.sql @@ -982,6 +982,7 @@ CREATE TABLE IF NOT EXISTS AuthGroups ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL UNIQUE, is_system BOOLEAN DEFAULT false, + mfa_required BOOLEAN NOT NULL DEFAULT false, created_at DATETIME, updated_at DATETIME ); diff --git a/src/data/models/authGroup.js b/src/data/models/authGroup.js index 8fcb9a51..0d216456 100644 --- a/src/data/models/authGroup.js +++ b/src/data/models/authGroup.js @@ -20,6 +20,12 @@ module.exports = (sequelize, DataTypes) => { allowNull: false, defaultValue: false, field: 'is_system' + }, + mfaRequired: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + field: 'mfa_required' } }, { tableName: 'AuthGroups', diff --git a/src/data/seeders/mysql/db_seeder_mysql_v3.8.0.sql b/src/data/seeders/mysql/db_seeder_mysql_v3.8.0.sql index 8700276b..1688af45 100644 --- a/src/data/seeders/mysql/db_seeder_mysql_v3.8.0.sql +++ b/src/data/seeders/mysql/db_seeder_mysql_v3.8.0.sql @@ -65,12 +65,12 @@ INSERT IGNORE INTO AuthPolicy ( ) VALUES (1, 12, true, true, true, 0, 5, 5, 15, 900, 3600, true, NULL); -INSERT IGNORE INTO AuthGroups (name, is_system) +INSERT IGNORE INTO AuthGroups (name, is_system, mfa_required) VALUES - ('admin', true), - ('sre', true), - ('developer', true), - ('viewer', true); + ('admin', true, false), + ('sre', true, false), + ('developer', true, false), + ('viewer', true, false); INSERT IGNORE INTO AuthBootstrapMeta (id, completed_at) VALUES (1, NULL); diff --git a/src/data/seeders/postgres/db_seeder_pg_v3.8.0.sql b/src/data/seeders/postgres/db_seeder_pg_v3.8.0.sql index 9971925e..b4c6a7a4 100644 --- a/src/data/seeders/postgres/db_seeder_pg_v3.8.0.sql +++ b/src/data/seeders/postgres/db_seeder_pg_v3.8.0.sql @@ -66,12 +66,12 @@ INSERT INTO "AuthPolicy" ( VALUES (1, 12, true, true, true, 0, 5, 5, 15, 900, 3600, true, NULL) ON CONFLICT (id) DO NOTHING; -INSERT INTO "AuthGroups" (name, is_system) +INSERT INTO "AuthGroups" (name, is_system, mfa_required) VALUES - ('admin', true), - ('sre', true), - ('developer', true), - ('viewer', true) + ('admin', true, false), + ('sre', true, false), + ('developer', true, false), + ('viewer', true, false) ON CONFLICT (name) DO NOTHING; INSERT INTO "AuthBootstrapMeta" (id, completed_at) diff --git a/src/data/seeders/sqlite/db_seeder_sqlite_v3.8.0.sql b/src/data/seeders/sqlite/db_seeder_sqlite_v3.8.0.sql index fe8bf0c9..8d6684bd 100644 --- a/src/data/seeders/sqlite/db_seeder_sqlite_v3.8.0.sql +++ b/src/data/seeders/sqlite/db_seeder_sqlite_v3.8.0.sql @@ -63,12 +63,12 @@ INSERT OR IGNORE INTO AuthPolicy ( ) VALUES (1, 12, true, true, true, 0, 5, 5, 15, 900, 3600, true, NULL); -INSERT OR IGNORE INTO AuthGroups (name, is_system) +INSERT OR IGNORE INTO AuthGroups (name, is_system, mfa_required) VALUES - ('admin', true), - ('sre', true), - ('developer', true), - ('viewer', true); + ('admin', true, false), + ('sre', true, false), + ('developer', true, false), + ('viewer', true, false); INSERT OR IGNORE INTO AuthBootstrapMeta (id, completed_at) VALUES (1, NULL); From 5fac62f980383a6b71343ea7d920d8ce860c7ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 22 Jun 2026 22:54:55 +0300 Subject: [PATCH 2/7] Drive embedded MFA from per-group mfaRequired and expose mfaEnabled on user profile. Group mfaRequired controls mandatory enrollment; enrolled users are always challenged at login. System groups allow mfaRequired updates but not rename. --- src/schemas/user.js | 7 +-- src/services/auth-bootstrap-service.js | 6 ++- src/services/auth-login-service.js | 9 +++- src/services/auth-mfa-service.js | 4 +- src/services/auth-user-service.js | 60 ++++++++++++++++++++------ 5 files changed, 65 insertions(+), 21 deletions(-) diff --git a/src/schemas/user.js b/src/schemas/user.js index bd965abb..de07cd24 100644 --- a/src/schemas/user.js +++ b/src/schemas/user.js @@ -120,7 +120,8 @@ const createAuthGroup = { id: '/createAuthGroup', type: 'object', properties: { - name: { type: 'string', minLength: 1 } + name: { type: 'string', minLength: 1 }, + mfaRequired: { type: 'boolean' } }, required: ['name'], additionalProperties: true @@ -130,9 +131,9 @@ const updateAuthGroup = { id: '/updateAuthGroup', type: 'object', properties: { - name: { type: 'string', minLength: 1 } + name: { type: 'string', minLength: 1 }, + mfaRequired: { type: 'boolean' } }, - required: ['name'], additionalProperties: true } diff --git a/src/services/auth-bootstrap-service.js b/src/services/auth-bootstrap-service.js index c70bb1cf..65345eac 100644 --- a/src/services/auth-bootstrap-service.js +++ b/src/services/auth-bootstrap-service.js @@ -46,7 +46,11 @@ async function ensureSystemGroups (transaction) { for (const name of SYSTEM_GROUPS) { await db.AuthGroup.findOrCreate({ where: { name }, - defaults: { name, isSystem: true }, + defaults: { + name, + isSystem: true, + mfaRequired: false + }, transaction }) } diff --git a/src/services/auth-login-service.js b/src/services/auth-login-service.js index f3b53e17..a5bea45f 100644 --- a/src/services/auth-login-service.js +++ b/src/services/auth-login-service.js @@ -1,7 +1,9 @@ 'use strict' const { decodeJwt } = require('jose') +const db = require('../data/models') const Errors = require('../helpers/errors') +const { withTransaction } = require('../helpers/app-helper') const AuthPolicyService = require('./auth-policy-service') const AuthPasswordService = require('./auth-password-service') const AuthTokenService = require('./auth-token-service') @@ -54,12 +56,17 @@ async function profile (req, transaction) { const accessToken = req.headers.authorization.replace('Bearer ', '') const claims = decodeJwt(accessToken) + const mfaRecord = claims.sub + ? await db.AuthMfa.findOne(withTransaction(transaction, { where: { userId: claims.sub } })) + : null + return { sub: claims.sub, email: claims.email, preferred_username: claims.preferred_username, groups: claims.groups || [], - password_change_required: claims.password_change_required === true + password_change_required: claims.password_change_required === true, + mfaEnabled: Boolean(mfaRecord && mfaRecord.enabled) } } diff --git a/src/services/auth-mfa-service.js b/src/services/auth-mfa-service.js index 81a55b0d..cb968d8b 100644 --- a/src/services/auth-mfa-service.js +++ b/src/services/auth-mfa-service.js @@ -24,7 +24,7 @@ function isMfaExempt (user) { } function userRequiresMfa (groups) { - return normalizeGroupNames(groups).includes(ADMIN_GROUP) + return (groups || []).some((group) => group.mfaRequired === true) } function userMustEnrollMfa (user, groups, mfaRecord) { @@ -38,7 +38,7 @@ function userRequiresMfaChallenge (user, groups, mfaRecord) { if (isMfaExempt(user)) { return false } - return userRequiresMfa(groups) && Boolean(mfaRecord && mfaRecord.enabled) + return Boolean(mfaRecord && mfaRecord.enabled) } async function loadUserAuthContext (email, transaction) { diff --git a/src/services/auth-user-service.js b/src/services/auth-user-service.js index 46db8c17..c3e3b4bd 100644 --- a/src/services/auth-user-service.js +++ b/src/services/auth-user-service.js @@ -406,12 +406,23 @@ function formatGroupResponse (group) { id: group.id, name: group.name, isSystem: group.isSystem, + mfaRequired: Boolean(group.mfaRequired), createdAt: group.createdAt, updatedAt: group.updatedAt } } -async function createGroup ({ name }, transaction) { +function parseMfaRequired (value) { + if (value === undefined) { + return undefined + } + if (typeof value !== 'boolean') { + throw new Errors.ValidationError('mfaRequired must be a boolean') + } + return value +} + +async function createGroup ({ name, mfaRequired = false }, transaction) { const normalizedName = normalizeGroupName(name) if (SYSTEM_GROUP_NAMES.includes(normalizedName)) { throw new Errors.ConflictError('A system group with this name already exists') @@ -424,9 +435,11 @@ async function createGroup ({ name }, transaction) { throw new Errors.ConflictError('A group with this name already exists') } + const parsedMfaRequired = parseMfaRequired(mfaRequired) const group = await db.AuthGroup.create({ name: normalizedName, - isSystem: false + isSystem: false, + mfaRequired: parsedMfaRequired === undefined ? false : parsedMfaRequired }, withTransaction(transaction)) return formatGroupResponse(group) @@ -446,24 +459,43 @@ async function updateGroup (groupName, payload, transaction) { if (!group) { throw new Errors.NotFoundError('Group not found') } - if (group.isSystem) { - throw new Errors.ForbiddenError('System groups cannot be modified') + + const hasName = payload.name !== undefined + const hasMfaRequired = payload.mfaRequired !== undefined + + if (!hasName && !hasMfaRequired) { + throw new Errors.ValidationError('At least one of name or mfaRequired is required') } - const normalizedName = normalizeGroupName(payload.name) + if (group.isSystem) { + if (hasName) { + throw new Errors.ForbiddenError('System group names cannot be changed') + } + const mfaRequired = parseMfaRequired(payload.mfaRequired) + await group.update({ mfaRequired }, withTransaction(transaction)) + return formatGroupResponse(group) + } - const duplicate = await db.AuthGroup.findOne(withTransaction(transaction, { - where: { - name: normalizedName, - id: { [Op.ne]: group.id } + const updates = {} + if (hasMfaRequired) { + updates.mfaRequired = parseMfaRequired(payload.mfaRequired) + } + if (hasName) { + const normalizedName = normalizeGroupName(payload.name) + const duplicate = await db.AuthGroup.findOne(withTransaction(transaction, { + where: { + name: normalizedName, + id: { [Op.ne]: group.id } + } + })) + if (duplicate) { + throw new Errors.ConflictError('A group with this name already exists') } - })) - if (duplicate) { - throw new Errors.ConflictError('A group with this name already exists') + updates.name = normalizedName } - await group.update({ name: normalizedName }, withTransaction(transaction)) - return getGroup(normalizedName, transaction) + await group.update(updates, withTransaction(transaction)) + return getGroup(group.name, transaction) } async function deleteGroup (groupName, transaction) { From 7237019e7d6291b8ad4408cceb8418f91f0b8d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 22 Jun 2026 22:55:01 +0300 Subject: [PATCH 3/7] Document auth group mfaRequired and embedded user profile response in Swagger. Updates GET /user/profile, auth group create/update schemas, and PATCH /groups/:name behavior for system groups. --- docs/swagger.yaml | 64 ++++++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7a4512f3..00ee338d 100755 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -3131,6 +3131,11 @@ paths: tags: - User summary: Get current user profile data + description: | + In **embedded** auth mode, returns a flat profile object derived from the access token + plus embedded MFA enrollment state (`mfaEnabled`). + + In **external** auth mode, returns the upstream IdP UserInfo payload (shape depends on the IdP). operationId: getUserProfile security: - authToken: [] @@ -3882,8 +3887,12 @@ paths: patch: tags: - Auth Groups - summary: Update custom auth group + summary: Update auth group operationId: updateAuthGroup + description: > + Updates a custom auth group name and/or `mfaRequired`. System groups (`admin`, `sre`, + `developer`, `viewer`) allow `mfaRequired` changes only; rename is forbidden. Requires RBAC + `authGroups` resource with `patch` verb. security: - authToken: [] parameters: @@ -8946,32 +8955,27 @@ components: type: string UserProfileDetailsResponse: type: object + description: | + Embedded auth mode response. External auth mode returns the IdP UserInfo object instead + (may include different fields; `mfaEnabled` is not supplied by Controller in external mode). properties: - userinfo: + sub: + type: string + email: + type: string + description: Omitted when the login identifier has no `@` (e.g. bootstrap username). + preferred_username: + type: string + groups: type: array items: - properties: - sub: - type: string - SubscriptionKey: - type: string - email_verified: - type: string - name: - type: string - preferred_username: - type: string - locale: - type: string - given_name: - type: string - family_name: - type: string - email: - type: string - password_change_required: - type: boolean - description: Present when JWT claim `password_change_required` is true + type: string + password_change_required: + type: boolean + description: Present when JWT claim `password_change_required` is true + mfaEnabled: + type: boolean + description: Embedded auth only — whether the user has enrolled TOTP MFA (login requires TOTP when true, except bootstrap) ChangePasswordRequest: type: object required: @@ -9067,13 +9071,19 @@ components: properties: name: type: string + mfaRequired: + type: boolean + default: false + description: When true, members of this group must enroll and use TOTP MFA at login AuthGroupUpdateRequest: type: object - required: - - name + description: At least one of `name` or `mfaRequired` is required properties: name: type: string + mfaRequired: + type: boolean + description: When true, members of this group must enroll and use TOTP MFA at login AuthGroupResponse: type: object properties: @@ -9083,6 +9093,8 @@ components: type: string isSystem: type: boolean + mfaRequired: + type: boolean createdAt: type: string format: date-time From fdf80227de1645ac2e5821ba52eef05d4cab2d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 22 Jun 2026 22:55:07 +0300 Subject: [PATCH 4/7] Add tests for per-group MFA policy, voluntary MFA login, and profile mfaEnabled. Updates embedded auth harness defaults and OIDC manual test notes for group-based MFA. --- test/oidc/README.md | 2 +- test/src/services/auth-integration.test.js | 73 +++++++++++++++++++ .../services/auth-interaction-service.test.js | 38 ++++++++-- .../services/auth-user-service-groups.test.js | 46 +++++++++++- test/src/services/user-service-oidc.test.js | 23 ++++++ test/support/embedded-auth-harness.js | 8 +- 6 files changed, 179 insertions(+), 11 deletions(-) diff --git a/test/oidc/README.md b/test/oidc/README.md index ac6595b5..767b1828 100644 --- a/test/oidc/README.md +++ b/test/oidc/README.md @@ -78,7 +78,7 @@ Test harness: `test/support/embedded-auth-harness.js` (in-memory auth store + em curl -H "Authorization: Bearer " http://localhost:51121/api/v3/user/profile ``` -7. Admin accounts require MFA to be enrolled before login succeeds (except **bootstrap admin** with `isBootstrap: true`). Login accepts optional `totp` on the same request; missing or invalid MFA returns **401**. Enroll/confirm via Bearer on `/user/mfa/enroll` and `/user/mfa/confirm`. +7. **Group policy:** users in auth groups with `mfaRequired: true` must enroll MFA before login succeeds (except **bootstrap admin** with `isBootstrap: true`). All system groups including `admin` default to `mfaRequired: false` on install; operators enable MFA per group via `PATCH /api/v3/groups/:name` (requires RBAC `authGroups` patch). **Voluntary MFA:** users who enroll via `/user/mfa/enroll` + confirm (`mfaEnabled: true`) are always challenged with TOTP at login even when no group requires MFA. Login accepts optional `totp` on the same request; missing or invalid MFA returns **401**. Enroll/confirm via Bearer on `/user/mfa/enroll` and `/user/mfa/confirm`. ## External IdP smoke diff --git a/test/src/services/auth-integration.test.js b/test/src/services/auth-integration.test.js index 5667be3a..e63527c1 100644 --- a/test/src/services/auth-integration.test.js +++ b/test/src/services/auth-integration.test.js @@ -48,6 +48,7 @@ describe('Embedded auth integration', () => { it('issues tokens for admin users with MFA when totp is provided', async () => { const { store, modules } = await $harness + store.groups.get('admin').mfaRequired = true const totpSecret = generateSecret() await store.seedUser({ email: 'admin@example.com', @@ -71,6 +72,7 @@ describe('Embedded auth integration', () => { it('rejects admin login without totp when MFA is enabled', async () => { const { store, modules } = await $harness + store.groups.get('admin').mfaRequired = true await store.seedUser({ email: 'admin@example.com', groupNames: ['admin'], @@ -89,6 +91,36 @@ describe('Embedded auth integration', () => { } }) + it('requires totp for voluntary MFA when group mfaRequired is false', async () => { + const { store, modules } = await $harness + const totpSecret = generateSecret() + await store.seedUser({ + email: 'mfa-viewer@example.com', + groupNames: ['viewer'], + mfaEnabled: true, + totpSecret + }) + + try { + await modules.UserService.login({ + email: 'mfa-viewer@example.com', + password: DEFAULT_TEST_PASSWORD + }, false) + expect.fail('expected login to fail') + } catch (error) { + expect(error).to.be.instanceOf(Errors.InvalidCredentialsError) + } + + const code = generateSync({ secret: totpSecret }) + const result = await modules.UserService.login({ + email: 'mfa-viewer@example.com', + password: DEFAULT_TEST_PASSWORD, + totp: code + }, false) + + expect(result.accessToken).to.be.a('string').that.is.not.empty + }) + it('allows bootstrap admin login without MFA', async () => { const { store, modules } = await $harness await store.seedUser({ @@ -110,6 +142,7 @@ describe('Embedded auth integration', () => { it('rejects non-bootstrap admin login when MFA is not enrolled', async () => { const { store, modules } = await $harness + store.groups.get('admin').mfaRequired = true await store.seedUser({ email: 'newadmin@example.com', groupNames: ['admin'] @@ -126,6 +159,46 @@ describe('Embedded auth integration', () => { } }) + it('allows admin login without MFA by default on install', async () => { + const { store, modules } = await $harness + await store.seedUser({ + email: 'newadmin@example.com', + groupNames: ['admin'] + }) + + const result = await modules.UserService.login({ + email: 'newadmin@example.com', + password: DEFAULT_TEST_PASSWORD + }, false) + + expect(result.accessToken).to.be.a('string').that.is.not.empty + }) + + it('requires MFA enrollment for custom group with mfaRequired true', async () => { + const { store, modules } = await $harness + const groupId = 'grp-secops' + store.groups.set('secops', { + id: groupId, + name: 'secops', + isSystem: false, + mfaRequired: true + }) + await store.seedUser({ + email: 'secops@example.com', + groupNames: ['secops'] + }) + + try { + await modules.UserService.login({ + email: 'secops@example.com', + password: DEFAULT_TEST_PASSWORD + }, false) + expect.fail('expected login to fail') + } catch (error) { + expect(error).to.be.instanceOf(Errors.InvalidCredentialsError) + } + }) + it('rotates refresh tokens', async () => { const { store, modules } = await $harness await store.seedUser({ diff --git a/test/src/services/auth-interaction-service.test.js b/test/src/services/auth-interaction-service.test.js index 8cc139f0..45c855dd 100644 --- a/test/src/services/auth-interaction-service.test.js +++ b/test/src/services/auth-interaction-service.test.js @@ -66,7 +66,17 @@ describe('Auth interaction service', () => { it('returns mfa for admin with MFA enabled after login', () => { const authContext = { user: { id: 'user-1', mustChangePassword: false, isBootstrap: false }, - groups: [{ name: 'admin' }], + groups: [{ name: 'admin', mfaRequired: true }], + mfa: { enabled: true } + } + const state = { userId: 'user-1' } + expect(AuthInteractionService.resolveNextStep(authContext, state)).to.equal('mfa') + }) + + it('returns mfa for viewer with voluntary MFA when group mfaRequired is false', () => { + const authContext = { + user: { id: 'user-1', mustChangePassword: false, isBootstrap: false }, + groups: [{ name: 'viewer', mfaRequired: false }], mfa: { enabled: true } } const state = { userId: 'user-1' } @@ -76,7 +86,7 @@ describe('Auth interaction service', () => { it('returns enroll for admin without MFA enrollment', () => { const authContext = { user: { id: 'user-1', mustChangePassword: false, isBootstrap: false }, - groups: [{ name: 'admin' }], + groups: [{ name: 'admin', mfaRequired: true }], mfa: null } const state = { userId: 'user-1' } @@ -86,7 +96,7 @@ describe('Auth interaction service', () => { it('returns change-password for viewer with mustChangePassword after login', () => { const authContext = { user: { id: 'user-1', mustChangePassword: true, isBootstrap: false }, - groups: [{ name: 'viewer' }], + groups: [{ name: 'viewer', mfaRequired: false }], mfa: null } const state = { userId: 'user-1' } @@ -96,7 +106,7 @@ describe('Auth interaction service', () => { it('returns complete for viewer after forced password change', () => { const authContext = { user: { id: 'user-1', mustChangePassword: false, isBootstrap: false }, - groups: [{ name: 'viewer' }], + groups: [{ name: 'viewer', mfaRequired: false }], mfa: null } const state = { userId: 'user-1' } @@ -106,7 +116,7 @@ describe('Auth interaction service', () => { it('returns complete for viewer after login when password change is not required', () => { const authContext = { user: { id: 'user-1', mustChangePassword: false, isBootstrap: false }, - groups: [{ name: 'viewer' }], + groups: [{ name: 'viewer', mfaRequired: false }], mfa: null } const state = { userId: 'user-1', passwordChanged: true } @@ -183,6 +193,7 @@ describe('Auth interaction service', () => { it('returns mfa step for admin with MFA enabled', async () => { const { store } = await $harness + store.groups.get('admin').mfaRequired = true const totpSecret = generateSecret() await store.seedUser({ email: 'admin@example.com', @@ -199,6 +210,22 @@ describe('Auth interaction service', () => { expect(result.step).to.equal('mfa') }) + it('returns mfa step for viewer with voluntary MFA when group mfaRequired is false', async () => { + const { store } = await $harness + await store.seedUser({ + email: 'mfa-viewer@example.com', + groupNames: ['viewer'], + mfaEnabled: true + }) + + const result = await AuthInteractionService.submitLogin($interactionUid, { + email: 'mfa-viewer@example.com', + password: DEFAULT_TEST_PASSWORD + }, false) + + expect(result.step).to.equal('mfa') + }) + it('rejects invalid credentials with 401', async () => { const { store } = await $harness await store.seedUser({ @@ -219,6 +246,7 @@ describe('Auth interaction service', () => { it('completes interaction and returns OAuth resume URL after MFA', async () => { const { store } = await $harness + store.groups.get('admin').mfaRequired = true const totpSecret = generateSecret() await store.seedUser({ email: 'admin@example.com', diff --git a/test/src/services/auth-user-service-groups.test.js b/test/src/services/auth-user-service-groups.test.js index b9d83273..a2e96fe4 100644 --- a/test/src/services/auth-user-service-groups.test.js +++ b/test/src/services/auth-user-service-groups.test.js @@ -16,6 +16,7 @@ function createGroupRecord (data) { id: 4, name: 'viewer', isSystem: true, + mfaRequired: false, createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-01T00:00:00Z'), ...data @@ -55,7 +56,7 @@ describe('auth-user-service groups by name', () => { const { getGroup } = reloadAuthUserService() const result = await getGroup('viewer') - expect(result).to.deep.include({ id: 4, name: 'viewer', isSystem: true }) + expect(result).to.deep.include({ id: 4, name: 'viewer', isSystem: true, mfaRequired: false }) expect(db.AuthGroup.findOne.firstCall.args[0].where).to.deep.equal({ name: 'viewer' }) }) @@ -96,4 +97,47 @@ describe('auth-user-service groups by name', () => { expect(error).to.be.instanceOf(Errors.ForbiddenError) } }) + + it('updates mfaRequired on a system group', async () => { + const db = require('../../../src/data/models') + const adminGroup = createGroupRecord({ id: 1, name: 'admin', isSystem: true, mfaRequired: true }) + stubModelMethod(db, 'AuthGroup', 'findOne', $sandbox, async () => adminGroup) + + const { updateGroup } = reloadAuthUserService() + const result = await updateGroup('admin', { mfaRequired: false }) + + expect(result.mfaRequired).to.equal(false) + expect(adminGroup.mfaRequired).to.equal(false) + }) + + it('rejects renaming a system group', async () => { + const db = require('../../../src/data/models') + stubModelMethod(db, 'AuthGroup', 'findOne', $sandbox, async () => $viewerGroup) + + const { updateGroup } = reloadAuthUserService() + + try { + await updateGroup('viewer', { name: 'read-only' }) + expect.fail('expected forbidden') + } catch (error) { + expect(error).to.be.instanceOf(Errors.ForbiddenError) + } + }) + + it('creates a custom group with mfaRequired', async () => { + const db = require('../../../src/data/models') + stubModelMethod(db, 'AuthGroup', 'findOne', $sandbox, async () => null) + stubModelMethod(db, 'AuthGroup', 'create', $sandbox, async (values) => createGroupRecord({ + id: 10, + isSystem: false, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + ...values + })) + + const { createGroup } = reloadAuthUserService() + const result = await createGroup({ name: 'secops', mfaRequired: true }) + + expect(result).to.deep.include({ name: 'secops', isSystem: false, mfaRequired: true }) + }) }) diff --git a/test/src/services/user-service-oidc.test.js b/test/src/services/user-service-oidc.test.js index 86da74bc..2950ed4f 100644 --- a/test/src/services/user-service-oidc.test.js +++ b/test/src/services/user-service-oidc.test.js @@ -105,6 +105,29 @@ describe('User service OIDC', () => { expect(profile.preferred_username).to.equal('viewer@example.com') expect(profile.email).to.equal('viewer@example.com') expect(profile.groups).to.deep.equal(['viewer']) + expect(profile.mfaEnabled).to.equal(false) + }) + + it('returns mfaEnabled true when the user has enrolled MFA', async () => { + const { store, modules } = await $harness + await store.seedUser({ + email: 'mfa-user@example.com', + groupNames: ['viewer'], + mfaEnabled: true + }) + + const loginResult = await modules.UserService.login({ + email: 'mfa-user@example.com', + password: DEFAULT_TEST_PASSWORD + }, false) + + const profile = await modules.UserService.profile({ + headers: { + authorization: `Bearer ${loginResult.accessToken}` + } + }, false) + + expect(profile.mfaEnabled).to.equal(true) }) }) diff --git a/test/support/embedded-auth-harness.js b/test/support/embedded-auth-harness.js index b739234c..84dfc603 100644 --- a/test/support/embedded-auth-harness.js +++ b/test/support/embedded-auth-harness.js @@ -69,10 +69,10 @@ function stubModelMethod (db, modelName, methodName, sandbox, impl) { function createEmbeddedAuthStore () { const groups = new Map([ - ['admin', createRecord({ id: 'grp-admin', name: 'admin', isSystem: true })], - ['sre', createRecord({ id: 'grp-sre', name: 'sre', isSystem: true })], - ['developer', createRecord({ id: 'grp-developer', name: 'developer', isSystem: true })], - ['viewer', createRecord({ id: 'grp-viewer', name: 'viewer', isSystem: true })] + ['admin', createRecord({ id: 'grp-admin', name: 'admin', isSystem: true, mfaRequired: false })], + ['sre', createRecord({ id: 'grp-sre', name: 'sre', isSystem: true, mfaRequired: false })], + ['developer', createRecord({ id: 'grp-developer', name: 'developer', isSystem: true, mfaRequired: false })], + ['viewer', createRecord({ id: 'grp-viewer', name: 'viewer', isSystem: true, mfaRequired: false })] ]) const users = new Map() From 706f5cc85a62efa006e39dce06d6f321e37a3a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 22 Jun 2026 22:55:12 +0300 Subject: [PATCH 5/7] Document per-group MFA policy and user profile mfaEnabled in v3.8.0 changelog. Replaces admin-only MFA wording with mfaRequired defaults and voluntary enrollment login behavior. --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6bba425..e67bd39a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,7 +77,8 @@ Controller v3.8 is a **greenfield** release aligned with **Edgelet**. There is * ### Added -- Embedded OIDC identity service with TOTP MFA (mandatory for `admin` group). +- Embedded OIDC identity service with TOTP MFA (`mfaRequired` per auth group; all system groups default to MFA off on install). +- **`GET /api/v3/user/profile`** (embedded mode) — includes **`mfaEnabled`** for the authenticated user. - **`POST /api/v3/auth/migration/export`** — one-way embedded → external IdP migration. - **`POST /api/v3/auth/jwks/rotate`** — manual JWKS rotation (embedded mode). - Built-in rate limiting on auth endpoints. @@ -105,6 +106,7 @@ Controller v3.8 is a **greenfield** release aligned with **Edgelet**. There is * ### Changed +- Embedded auth login MFA challenge — users with enrolled TOTP (**`mfaEnabled`**) are always prompted for TOTP at login, including voluntary My Account enrollment when all groups have **`mfaRequired: false`**. Group **`mfaRequired`** still forces enrollment for members who have not enrolled. - OpenTelemetry SDK dependencies bumped to **0.219.x**; telemetry init simplified (removed custom resource detector wrapper). - **`js-yaml`** bumped to **4.2.0**. From f1c15d5d0d54085944edf7d7e12b08c58fa1a6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 22 Jun 2026 22:55:16 +0300 Subject: [PATCH 6/7] Pin undici 7.28.0 as a direct dependency. --- package-lock.json | 8 ++++---- package.json | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 701fad18..8942d252 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13583,13 +13583,13 @@ } }, "node_modules/undici": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", - "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "optional": true, "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 463e31cf..191b88e8 100644 --- a/package.json +++ b/package.json @@ -169,6 +169,7 @@ "serialize-javascript": "7.0.5", "diff": "8.0.3", "form-data": "^4.0.6", - "js-yaml": "4.2.0" + "js-yaml": "4.2.0", + "undici": "7.28.0" } } From 4064e05f4a5945f9af7cf260ed8b6a0891481869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 22 Jun 2026 23:17:26 +0300 Subject: [PATCH 7/7] (test): auth tests fixed --- test/src/services/auth-login.test.js | 29 +++++++++++++++++++-- test/src/services/user-service-oidc.test.js | 6 +++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/test/src/services/auth-login.test.js b/test/src/services/auth-login.test.js index 48998611..64a61f31 100644 --- a/test/src/services/auth-login.test.js +++ b/test/src/services/auth-login.test.js @@ -97,11 +97,11 @@ describe('Embedded auth login service', () => { } }) - it('throws InvalidCredentialsError for non-bootstrap admin without MFA enrolled', async () => { + it('throws InvalidCredentialsError for non-bootstrap admin without MFA when group mfaRequired is true', async () => { const user = { id: 'admin-2', email: 'admin2@example.com', passwordHash: 'hash', isBootstrap: false } $sandbox.stub(AuthMfaService, 'loadUserAuthContext').resolves({ user, - groups: [{ name: 'admin' }], + groups: [{ name: 'admin', mfaRequired: true }], mfa: null, groupNames: ['admin'] }) @@ -120,6 +120,31 @@ describe('Embedded auth login service', () => { } }) + it('returns tokens for non-bootstrap admin without MFA when group mfaRequired is false', async () => { + const user = { id: 'admin-2', email: 'admin2@example.com', passwordHash: 'hash', isBootstrap: false } + $sandbox.stub(AuthMfaService, 'loadUserAuthContext').resolves({ + user, + groups: [{ name: 'admin', mfaRequired: false }], + mfa: null, + groupNames: ['admin'] + }) + $sandbox.stub(AuthPolicyService, 'getPolicy').resolves(AuthPolicyService.DEFAULT_POLICY) + $sandbox.stub(AuthPolicyService, 'isAccountLocked').returns(false) + $sandbox.stub(AuthPasswordService, 'verifyPassword').resolves(true) + $sandbox.stub(AuthPolicyService, 'resetFailedLogin').resolves(user) + $sandbox.stub(AuthTokenService, 'issueTokenPair').resolves({ + accessToken: 'access-token', + refreshToken: 'refresh-token' + }) + + const result = await AuthLoginService.login({ + email: 'admin2@example.com', + password: 'correct-password' + }) + + expect(result.accessToken).to.equal('access-token') + }) + it('returns tokens for bootstrap admin without MFA', async () => { const user = { id: 'bootstrap-1', email: 'bootstrap@example.com', passwordHash: 'hash', isBootstrap: true } $sandbox.stub(AuthMfaService, 'loadUserAuthContext').resolves({ diff --git a/test/src/services/user-service-oidc.test.js b/test/src/services/user-service-oidc.test.js index 2950ed4f..4c1e80ec 100644 --- a/test/src/services/user-service-oidc.test.js +++ b/test/src/services/user-service-oidc.test.js @@ -1,5 +1,6 @@ const { expect } = require('chai') const sinon = require('sinon') +const { generateSync } = require('otplib') const { snapshotOidcEnv, @@ -110,7 +111,7 @@ describe('User service OIDC', () => { it('returns mfaEnabled true when the user has enrolled MFA', async () => { const { store, modules } = await $harness - await store.seedUser({ + const { totpSecret } = await store.seedUser({ email: 'mfa-user@example.com', groupNames: ['viewer'], mfaEnabled: true @@ -118,7 +119,8 @@ describe('User service OIDC', () => { const loginResult = await modules.UserService.login({ email: 'mfa-user@example.com', - password: DEFAULT_TEST_PASSWORD + password: DEFAULT_TEST_PASSWORD, + totp: generateSync({ secret: totpSecret }) }, false) const profile = await modules.UserService.profile({