Skip to content

Commit 6de8bc9

Browse files
committed
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 <noreply@anthropic.com>
1 parent abd5acd commit 6de8bc9

5 files changed

Lines changed: 85 additions & 12 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): the password an administrator or user types into an action dialog is masked, no longer shown in clear text
6+
7+
Clause-②: no (an input's presentation: the param still accepts the same string, and no accept set or served shape moves)
8+
9+
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:
10+
11+
- `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`.
12+
- `sys_two_factor`: the `password` of `enable_two_factor`, `disable_two_factor` and `regenerate_backup_codes`.
13+
- `sys_sso_provider`: `register_sso_provider`'s `clientSecret`.
14+
15+
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.

‎packages/platform-objects/src/identity/sys-sso-provider.object.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const SysSsoProvider = ObjectSchema.create({
110110
{ 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.' },
111111
{ name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP, e.g. acme.com.' },
112112
{ name: 'clientId', label: 'Client ID', type: 'text', required: true, helpText: 'OAuth client ID issued by the IdP for this system.' },
113-
{ name: 'clientSecret', label: 'Client Secret', type: 'text', required: true, helpText: 'OAuth client secret (stored encrypted by better-auth).' },
113+
{ name: 'clientSecret', label: 'Client Secret', type: 'password', required: true, helpText: 'OAuth client secret (stored encrypted by better-auth).' },
114114
{ name: 'discoveryEndpoint', label: 'Discovery URL', type: 'text', required: false, helpText: 'Optional. OIDC discovery document URL. Leave blank to derive `<issuer>/.well-known/openid-configuration`.' },
115115
{ 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".' },
116116
// No "Map: User ID claim" param here on purpose. `@better-auth/sso@1.7.0-rc.2`

‎packages/platform-objects/src/identity/sys-two-factor.object.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export const SysTwoFactor = ObjectSchema.create({
8282
requiresFeature: 'twoFactor',
8383
refreshAfter: true,
8484
params: [
85-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
85+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
8686
],
8787
resultDialog: {
8888
title: 'Two-factor authentication enabled',
@@ -110,7 +110,7 @@ export const SysTwoFactor = ObjectSchema.create({
110110
successMessage: '2FA disabled',
111111
refreshAfter: true,
112112
params: [
113-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
113+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
114114
],
115115
},
116116
{
@@ -126,7 +126,7 @@ export const SysTwoFactor = ObjectSchema.create({
126126
description: 'Regenerate backup codes? All previous backup codes will stop working immediately.',
127127
refreshAfter: true,
128128
params: [
129-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
129+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
130130
],
131131
resultDialog: {
132132
title: 'New backup codes generated',

‎packages/platform-objects/src/identity/sys-user.object.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export const SysUser = ObjectSchema.create({
248248
requiresFeature: 'phoneNumber',
249249
},
250250
{ field: 'name', required: false },
251-
{ name: 'password', label: 'Password (leave empty to generate)', type: 'text', required: false },
251+
{ name: 'password', label: 'Password (leave empty to generate)', type: 'password', required: false },
252252
{
253253
name: 'mustChangePassword',
254254
label: 'Require Password Change On First Login',
@@ -296,7 +296,7 @@ export const SysUser = ObjectSchema.create({
296296
// `generatePassword` rides as a constant so an empty field generates.
297297
bodyExtra: { generatePassword: true },
298298
params: [
299-
{ name: 'newPassword', label: 'New Password (leave empty to generate)', type: 'text', required: false },
299+
{ name: 'newPassword', label: 'New Password (leave empty to generate)', type: 'password', required: false },
300300
{
301301
name: 'mustChangePassword',
302302
label: 'Require Password Change On Next Login',
@@ -505,8 +505,8 @@ export const SysUser = ObjectSchema.create({
505505
successMessage: 'Password changed',
506506
refreshAfter: false,
507507
params: [
508-
{ name: 'currentPassword', label: 'Current Password', type: 'text', required: true },
509-
{ name: 'newPassword', label: 'New Password', type: 'text', required: true },
508+
{ name: 'currentPassword', label: 'Current Password', type: 'password', required: true },
509+
{ name: 'newPassword', label: 'New Password', type: 'password', required: true },
510510
{ name: 'revokeOtherSessions', label: 'Sign out other devices', type: 'boolean', required: false, defaultValue: true },
511511
],
512512
},
@@ -559,7 +559,7 @@ export const SysUser = ObjectSchema.create({
559559
successMessage: 'Account deleted',
560560
refreshAfter: false,
561561
params: [
562-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
562+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
563563
],
564564
},
565565
// ── Two-factor authentication ─────────────────────────────────
@@ -594,7 +594,7 @@ export const SysUser = ObjectSchema.create({
594594
requiresFeature: 'twoFactor',
595595
refreshAfter: true,
596596
params: [
597-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
597+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
598598
],
599599
// No `successMessage`: `resultDialog` suppresses it, so one declared
600600
// here would be unreachable text that still ships to every translator.
@@ -623,7 +623,7 @@ export const SysUser = ObjectSchema.create({
623623
successMessage: 'Two-factor authentication disabled.',
624624
refreshAfter: true,
625625
params: [
626-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
626+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
627627
],
628628
},
629629
{
@@ -643,7 +643,7 @@ export const SysUser = ObjectSchema.create({
643643
description: 'Generate a new set of backup codes? Any previously generated codes will stop working.',
644644
refreshAfter: false,
645645
params: [
646-
{ name: 'password', label: 'Current Password', type: 'text', required: true },
646+
{ name: 'password', label: 'Current Password', type: 'password', required: true },
647647
],
648648
// No `successMessage` — see the note above `enable_two_factor`. The toast
649649
// this replaces ("New backup codes generated — save them somewhere safe")

‎packages/platform-objects/src/platform-objects.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
SysUserPreference,
2626
SysVerification,
2727
} from './identity/index.js';
28+
import * as identityObjects from './identity/index.js';
2829
// RBAC objects (SysRole/SysPermissionSet/… + defaultPermissionSets) moved to
2930
// @objectstack/plugin-security and the sharing objects to
3031
// @objectstack/plugin-sharing per ADR-0029 K2 — see their packages' tests.
@@ -94,6 +95,63 @@ describe('@objectstack/platform-objects', () => {
9495
});
9596
});
9697

98+
describe('credential action params declare `password`, so the dialog masks what is typed', () => {
99+
// An action param's `type` picks the console dialog's widget and nothing
100+
// else: no server path reads it (the endpoints take the posted string as
101+
// before), and the read mask above keys on FIELDS, never on params. A
102+
// `text` param renders `<input type="text">`, so a typed password is
103+
// readable on screen and in screenshots; `password` renders the masked
104+
// input. The roster pins each param by name; the sweep over every identity
105+
// object fails on a credential-named param added later as `text`.
106+
const roster: Array<[string, { name: string; actions?: readonly unknown[] }, string, string]> = [
107+
['SysUser', SysUser, 'create_user', 'password'],
108+
['SysUser', SysUser, 'set_user_password', 'newPassword'],
109+
['SysUser', SysUser, 'change_my_password', 'currentPassword'],
110+
['SysUser', SysUser, 'change_my_password', 'newPassword'],
111+
['SysUser', SysUser, 'delete_my_account', 'password'],
112+
['SysUser', SysUser, 'enable_two_factor', 'password'],
113+
['SysUser', SysUser, 'disable_two_factor', 'password'],
114+
['SysUser', SysUser, 'generate_backup_codes', 'password'],
115+
['SysTwoFactor', SysTwoFactor, 'enable_two_factor', 'password'],
116+
['SysTwoFactor', SysTwoFactor, 'disable_two_factor', 'password'],
117+
['SysTwoFactor', SysTwoFactor, 'regenerate_backup_codes', 'password'],
118+
['SysSsoProvider', SysSsoProvider, 'register_sso_provider', 'clientSecret'],
119+
];
120+
121+
type Param = { name?: string; type?: string };
122+
type Action = { name?: string; params?: readonly Param[] };
123+
const actionsOf = (object: unknown): readonly Action[] =>
124+
((object as { actions?: readonly Action[] }).actions ?? []);
125+
126+
it.each(roster)('%s.%s#%s declares `password`', (_exportName, object, actionName, paramName) => {
127+
const action = actionsOf(object).find((a) => a.name === actionName);
128+
const param = (action?.params ?? []).find((p) => p.name === paramName);
129+
expect(param, `${object.name}.${actionName} must declare a '${paramName}' param`).toBeDefined();
130+
expect(param?.type).toBe('password');
131+
});
132+
133+
it('every credential-named action param on an identity object declares `password`', () => {
134+
const credential = /password|secret/i;
135+
const seen: string[] = [];
136+
const offenders: string[] = [];
137+
for (const object of Object.values(identityObjects)) {
138+
const objectName = (object as { name?: unknown }).name;
139+
if (typeof objectName !== 'string') continue;
140+
for (const action of actionsOf(object)) {
141+
for (const param of action.params ?? []) {
142+
if (!param.name || !credential.test(param.name)) continue;
143+
const key = `${objectName}.${action.name}#${param.name}`;
144+
seen.push(key);
145+
if (param.type !== 'password') offenders.push(`${key}: '${String(param.type)}'`);
146+
}
147+
}
148+
}
149+
// Positive control: the sweep reaches exactly the rostered params.
150+
expect(seen.sort()).toEqual(roster.map(([, o, a, p]) => `${o.name}.${a}#${p}`).sort());
151+
expect(offenders).toEqual([]);
152+
});
153+
});
154+
97155
describe('secure-by-default posture (ADR-0066 ④)', () => {
98156
// Raw secret / live-credential stores are opted OUT of the wildcard `'*'`
99157
// grant: only an explicit per-object grant or the posture-gated superuser

0 commit comments

Comments
 (0)