Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/24758-password-param-type.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<issuer>/.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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 },
],
},
{
Expand All @@ -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',
Expand Down
16 changes: 8 additions & 8 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 },
],
},
Expand Down Expand Up @@ -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 ─────────────────────────────────
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 },
],
},
{
Expand All @@ -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")
Expand Down
59 changes: 59 additions & 0 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -94,6 +95,64 @@ 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 `<input type="text">`, 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, 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 };
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, 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();
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 ?? []) {
// 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)}'`);
}
}
}
// Positive control: the sweep reaches exactly the rostered params.
expect(seen.sort()).toEqual(roster.map(([, a, p, o]) => `${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
Expand Down
Loading