From 6de8bc970a58277468d946c1265b94b24136ade2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:12:40 +0000 Subject: [PATCH 1/3] fix(platform-objects): credential action params declare type password so the dialog masks them Claude-Session: https://claude.ai/code/session_01G3YZBh5FVysqzzALckNTWy Co-authored-by: Claude --- .changeset/24758-password-param-type.md | 15 +++++ .../src/identity/sys-sso-provider.object.ts | 2 +- .../src/identity/sys-two-factor.object.ts | 6 +- .../src/identity/sys-user.object.ts | 16 ++--- .../src/platform-objects.test.ts | 58 +++++++++++++++++++ 5 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 .changeset/24758-password-param-type.md diff --git a/.changeset/24758-password-param-type.md b/.changeset/24758-password-param-type.md new file mode 100644 index 00000000000..317b53a47ab --- /dev/null +++ b/.changeset/24758-password-param-type.md @@ -0,0 +1,15 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): the password an administrator or user types into an action dialog is masked, no longer shown in clear text + +Clause-②: no (an input's presentation: the param still accepts the same string, and no accept set or served shape moves) + +Twelve credential params on the identity objects were declared `type: 'text'`, so the console's action dialog drew each as a plain text input and the typed password was readable on screen and in screenshots. Each now declares `type: 'password'`, which the dialog draws as a masked input: + +- `sys_user`: `create_user`'s `password` and `set_user_password`'s `newPassword` (Setup › Users), `change_my_password`'s `currentPassword` and `newPassword`, and the `password` of `delete_my_account`, `enable_two_factor`, `disable_two_factor` and `generate_backup_codes`. +- `sys_two_factor`: the `password` of `enable_two_factor`, `disable_two_factor` and `regenerate_backup_codes`. +- `sys_sso_provider`: `register_sso_provider`'s `clientSecret`. + +Labels, names, `required` and the posted body are unchanged: the value is still posted as the string typed, and no endpoint changes what it accepts. diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index e94741900f2..e73b7e33e26 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -110,7 +110,7 @@ export const SysSsoProvider = ObjectSchema.create({ { name: 'issuer', label: 'Issuer URL', type: 'text', required: true, helpText: 'IdP issuer, e.g. https://acme.okta.com. Discovery is fetched from here unless an explicit URL is given below.' }, { name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP, e.g. acme.com.' }, { name: 'clientId', label: 'Client ID', type: 'text', required: true, helpText: 'OAuth client ID issued by the IdP for this system.' }, - { name: 'clientSecret', label: 'Client Secret', type: 'text', required: true, helpText: 'OAuth client secret (stored encrypted by better-auth).' }, + { name: 'clientSecret', label: 'Client Secret', type: 'password', required: true, helpText: 'OAuth client secret (stored encrypted by better-auth).' }, { name: 'discoveryEndpoint', label: 'Discovery URL', type: 'text', required: false, helpText: 'Optional. OIDC discovery document URL. Leave blank to derive `/.well-known/openid-configuration`.' }, { name: 'scopes', label: 'Scopes', type: 'text', required: false, placeholder: 'openid email profile', helpText: 'Optional. Space- or comma-separated OAuth scopes. Defaults to "openid email profile".' }, // No "Map: User ID claim" param here on purpose. `@better-auth/sso@1.7.0-rc.2` diff --git a/packages/platform-objects/src/identity/sys-two-factor.object.ts b/packages/platform-objects/src/identity/sys-two-factor.object.ts index b142bf988ca..54370cc5d07 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -82,7 +82,7 @@ export const SysTwoFactor = ObjectSchema.create({ requiresFeature: 'twoFactor', refreshAfter: true, params: [ - { name: 'password', label: 'Current Password', type: 'text', required: true }, + { name: 'password', label: 'Current Password', type: 'password', required: true }, ], resultDialog: { title: 'Two-factor authentication enabled', @@ -110,7 +110,7 @@ export const SysTwoFactor = ObjectSchema.create({ successMessage: '2FA disabled', refreshAfter: true, params: [ - { name: 'password', label: 'Current Password', type: 'text', required: true }, + { name: 'password', label: 'Current Password', type: 'password', required: true }, ], }, { @@ -126,7 +126,7 @@ export const SysTwoFactor = ObjectSchema.create({ description: 'Regenerate backup codes? All previous backup codes will stop working immediately.', refreshAfter: true, params: [ - { name: 'password', label: 'Current Password', type: 'text', required: true }, + { name: 'password', label: 'Current Password', type: 'password', required: true }, ], resultDialog: { title: 'New backup codes generated', diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 8236bca5fd5..7abf5fe3a3f 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -248,7 +248,7 @@ export const SysUser = ObjectSchema.create({ requiresFeature: 'phoneNumber', }, { field: 'name', required: false }, - { name: 'password', label: 'Password (leave empty to generate)', type: 'text', required: false }, + { name: 'password', label: 'Password (leave empty to generate)', type: 'password', required: false }, { name: 'mustChangePassword', label: 'Require Password Change On First Login', @@ -296,7 +296,7 @@ export const SysUser = ObjectSchema.create({ // `generatePassword` rides as a constant so an empty field generates. bodyExtra: { generatePassword: true }, params: [ - { name: 'newPassword', label: 'New Password (leave empty to generate)', type: 'text', required: false }, + { name: 'newPassword', label: 'New Password (leave empty to generate)', type: 'password', required: false }, { name: 'mustChangePassword', label: 'Require Password Change On Next Login', @@ -505,8 +505,8 @@ export const SysUser = ObjectSchema.create({ successMessage: 'Password changed', refreshAfter: false, params: [ - { name: 'currentPassword', label: 'Current Password', type: 'text', required: true }, - { name: 'newPassword', label: 'New Password', type: 'text', required: true }, + { name: 'currentPassword', label: 'Current Password', type: 'password', required: true }, + { name: 'newPassword', label: 'New Password', type: 'password', required: true }, { name: 'revokeOtherSessions', label: 'Sign out other devices', type: 'boolean', required: false, defaultValue: true }, ], }, @@ -559,7 +559,7 @@ export const SysUser = ObjectSchema.create({ successMessage: 'Account deleted', refreshAfter: false, params: [ - { name: 'password', label: 'Current Password', type: 'text', required: true }, + { name: 'password', label: 'Current Password', type: 'password', required: true }, ], }, // ── Two-factor authentication ───────────────────────────────── @@ -594,7 +594,7 @@ export const SysUser = ObjectSchema.create({ requiresFeature: 'twoFactor', refreshAfter: true, params: [ - { name: 'password', label: 'Current Password', type: 'text', required: true }, + { name: 'password', label: 'Current Password', type: 'password', required: true }, ], // No `successMessage`: `resultDialog` suppresses it, so one declared // here would be unreachable text that still ships to every translator. @@ -623,7 +623,7 @@ export const SysUser = ObjectSchema.create({ successMessage: 'Two-factor authentication disabled.', refreshAfter: true, params: [ - { name: 'password', label: 'Current Password', type: 'text', required: true }, + { name: 'password', label: 'Current Password', type: 'password', required: true }, ], }, { @@ -643,7 +643,7 @@ export const SysUser = ObjectSchema.create({ description: 'Generate a new set of backup codes? Any previously generated codes will stop working.', refreshAfter: false, params: [ - { name: 'password', label: 'Current Password', type: 'text', required: true }, + { name: 'password', label: 'Current Password', type: 'password', required: true }, ], // No `successMessage` — see the note above `enable_two_factor`. The toast // this replaces ("New backup codes generated — save them somewhere safe") diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 82526cdf497..1f8c3c3c0f8 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -25,6 +25,7 @@ import { SysUserPreference, SysVerification, } from './identity/index.js'; +import * as identityObjects from './identity/index.js'; // RBAC objects (SysRole/SysPermissionSet/… + defaultPermissionSets) moved to // @objectstack/plugin-security and the sharing objects to // @objectstack/plugin-sharing per ADR-0029 K2 — see their packages' tests. @@ -94,6 +95,63 @@ describe('@objectstack/platform-objects', () => { }); }); + describe('credential action params declare `password`, so the dialog masks what is typed', () => { + // An action param's `type` picks the console dialog's widget and nothing + // else: no server path reads it (the endpoints take the posted string as + // before), and the read mask above keys on FIELDS, never on params. A + // `text` param renders ``, so a typed password is + // readable on screen and in screenshots; `password` renders the masked + // input. The roster pins each param by name; the sweep over every identity + // object fails on a credential-named param added later as `text`. + const roster: Array<[string, { name: string; actions?: readonly unknown[] }, string, string]> = [ + ['SysUser', SysUser, 'create_user', 'password'], + ['SysUser', SysUser, 'set_user_password', 'newPassword'], + ['SysUser', SysUser, 'change_my_password', 'currentPassword'], + ['SysUser', SysUser, 'change_my_password', 'newPassword'], + ['SysUser', SysUser, 'delete_my_account', 'password'], + ['SysUser', SysUser, 'enable_two_factor', 'password'], + ['SysUser', SysUser, 'disable_two_factor', 'password'], + ['SysUser', SysUser, 'generate_backup_codes', 'password'], + ['SysTwoFactor', SysTwoFactor, 'enable_two_factor', 'password'], + ['SysTwoFactor', SysTwoFactor, 'disable_two_factor', 'password'], + ['SysTwoFactor', SysTwoFactor, 'regenerate_backup_codes', 'password'], + ['SysSsoProvider', SysSsoProvider, 'register_sso_provider', 'clientSecret'], + ]; + + type Param = { name?: string; type?: string }; + type Action = { name?: string; params?: readonly Param[] }; + const actionsOf = (object: unknown): readonly Action[] => + ((object as { actions?: readonly Action[] }).actions ?? []); + + it.each(roster)('%s.%s#%s declares `password`', (_exportName, object, actionName, paramName) => { + const action = actionsOf(object).find((a) => a.name === actionName); + const param = (action?.params ?? []).find((p) => p.name === paramName); + expect(param, `${object.name}.${actionName} must declare a '${paramName}' param`).toBeDefined(); + expect(param?.type).toBe('password'); + }); + + it('every credential-named action param on an identity object declares `password`', () => { + const credential = /password|secret/i; + const seen: string[] = []; + const offenders: string[] = []; + for (const object of Object.values(identityObjects)) { + const objectName = (object as { name?: unknown }).name; + if (typeof objectName !== 'string') continue; + for (const action of actionsOf(object)) { + for (const param of action.params ?? []) { + if (!param.name || !credential.test(param.name)) continue; + const key = `${objectName}.${action.name}#${param.name}`; + seen.push(key); + if (param.type !== 'password') offenders.push(`${key}: '${String(param.type)}'`); + } + } + } + // Positive control: the sweep reaches exactly the rostered params. + expect(seen.sort()).toEqual(roster.map(([, o, a, p]) => `${o.name}.${a}#${p}`).sort()); + expect(offenders).toEqual([]); + }); + }); + describe('secure-by-default posture (ADR-0066 ④)', () => { // Raw secret / live-credential stores are opted OUT of the wildcard `'*'` // grant: only an explicit per-object grant or the posture-gated superuser From 2d002ac4faabaef52179783765c46438eceee780 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:17:46 +0000 Subject: [PATCH 2/3] test(platform-objects): the credential-param sweep skips boolean toggles about a password Claude-Session: https://claude.ai/code/session_01G3YZBh5FVysqzzALckNTWy Co-authored-by: Claude --- packages/platform-objects/src/platform-objects.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 1f8c3c3c0f8..79d2f54ebd6 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -139,7 +139,8 @@ describe('@objectstack/platform-objects', () => { if (typeof objectName !== 'string') continue; for (const action of actionsOf(object)) { for (const param of action.params ?? []) { - if (!param.name || !credential.test(param.name)) continue; + // A boolean toggle about a password (`mustChangePassword`) is no credential. + if (!param.name || !credential.test(param.name) || param.type === 'boolean') continue; const key = `${objectName}.${action.name}#${param.name}`; seen.push(key); if (param.type !== 'password') offenders.push(`${key}: '${String(param.type)}'`); From 61d7cafa4d71a73e4ace086a17186389201901e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:18:38 +0000 Subject: [PATCH 3/3] test(platform-objects): name the credential-param roster rows by action and param Claude-Session: https://claude.ai/code/session_01G3YZBh5FVysqzzALckNTWy Co-authored-by: Claude --- .../src/platform-objects.test.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 79d2f54ebd6..b3141cb6641 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -103,19 +103,19 @@ describe('@objectstack/platform-objects', () => { // readable on screen and in screenshots; `password` renders the masked // input. The roster pins each param by name; the sweep over every identity // object fails on a credential-named param added later as `text`. - const roster: Array<[string, { name: string; actions?: readonly unknown[] }, string, string]> = [ - ['SysUser', SysUser, 'create_user', 'password'], - ['SysUser', SysUser, 'set_user_password', 'newPassword'], - ['SysUser', SysUser, 'change_my_password', 'currentPassword'], - ['SysUser', SysUser, 'change_my_password', 'newPassword'], - ['SysUser', SysUser, 'delete_my_account', 'password'], - ['SysUser', SysUser, 'enable_two_factor', 'password'], - ['SysUser', SysUser, 'disable_two_factor', 'password'], - ['SysUser', SysUser, 'generate_backup_codes', 'password'], - ['SysTwoFactor', SysTwoFactor, 'enable_two_factor', 'password'], - ['SysTwoFactor', SysTwoFactor, 'disable_two_factor', 'password'], - ['SysTwoFactor', SysTwoFactor, 'regenerate_backup_codes', 'password'], - ['SysSsoProvider', SysSsoProvider, 'register_sso_provider', 'clientSecret'], + const roster: Array<[string, string, string, { name: string; actions?: readonly unknown[] }]> = [ + ['SysUser', 'create_user', 'password', SysUser], + ['SysUser', 'set_user_password', 'newPassword', SysUser], + ['SysUser', 'change_my_password', 'currentPassword', SysUser], + ['SysUser', 'change_my_password', 'newPassword', SysUser], + ['SysUser', 'delete_my_account', 'password', SysUser], + ['SysUser', 'enable_two_factor', 'password', SysUser], + ['SysUser', 'disable_two_factor', 'password', SysUser], + ['SysUser', 'generate_backup_codes', 'password', SysUser], + ['SysTwoFactor', 'enable_two_factor', 'password', SysTwoFactor], + ['SysTwoFactor', 'disable_two_factor', 'password', SysTwoFactor], + ['SysTwoFactor', 'regenerate_backup_codes', 'password', SysTwoFactor], + ['SysSsoProvider', 'register_sso_provider', 'clientSecret', SysSsoProvider], ]; type Param = { name?: string; type?: string }; @@ -123,7 +123,7 @@ describe('@objectstack/platform-objects', () => { const actionsOf = (object: unknown): readonly Action[] => ((object as { actions?: readonly Action[] }).actions ?? []); - it.each(roster)('%s.%s#%s declares `password`', (_exportName, object, actionName, paramName) => { + it.each(roster)('%s.%s#%s declares `password`', (_exportName, actionName, paramName, object) => { const action = actionsOf(object).find((a) => a.name === actionName); const param = (action?.params ?? []).find((p) => p.name === paramName); expect(param, `${object.name}.${actionName} must declare a '${paramName}' param`).toBeDefined(); @@ -148,7 +148,7 @@ describe('@objectstack/platform-objects', () => { } } // Positive control: the sweep reaches exactly the rostered params. - expect(seen.sort()).toEqual(roster.map(([, o, a, p]) => `${o.name}.${a}#${p}`).sort()); + expect(seen.sort()).toEqual(roster.map(([, a, p, o]) => `${o.name}.${a}#${p}`).sort()); expect(offenders).toEqual([]); }); });