create-astram-app 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -6
- package/package.json +1 -1
- package/template/CLAUDE.md +2 -2
- package/template/README.md +4 -3
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +7 -1
- package/template/frontend/src/auth/confirmEmailLink.js +51 -0
- package/template/frontend/src/auth/confirmEmailLink.test.mjs +108 -0
- package/template/frontend/src/auth/deviceEnrollment.js +33 -0
- package/template/frontend/src/auth/deviceEnrollment.test.mjs +56 -0
- package/template/frontend/src/auth/profileEdit.js +58 -0
- package/template/frontend/src/auth/profileEdit.test.mjs +135 -0
- package/template/frontend/src/pages/ConfirmEmail.jsx +123 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +102 -20
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +39 -4
- package/template/frontend/vite.config.js +21 -1
- package/template/server/eslint.config.mjs +150 -36
- package/template/server/package.json +7 -6
- package/template/server/scripts/check-wall.mjs +66 -0
- package/template/server/src/app.module.ts +17 -23
package/README.md
CHANGED
|
@@ -64,9 +64,19 @@ my-app/
|
|
|
64
64
|
└── README.md how to run + how to add features the framework way
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
`server/` scripts: `dev`, `build`, `start`, `test`, `lint`, `migration:run`,
|
|
67
|
+
`server/` scripts: `dev`, `build`, `start`, `test`, `lint`, `lint:wall`, `migration:run`,
|
|
68
68
|
`migration:status`, `migration:revert`, `guide:sync`.
|
|
69
69
|
|
|
70
|
+
The wall (`eslint.config.mjs`, plus the `lint:wall` grep for `/internal` and
|
|
71
|
+
`node_modules/@astram` imports under `src/features`, run by `lint`) catches accidental use;
|
|
72
|
+
deliberate bypass (`.call`/`.apply`, a computed `pool[m]()`, an aliased `require`) is a
|
|
73
|
+
code-review matter.
|
|
74
|
+
|
|
75
|
+
The wall also flags uuid-named route and body inputs (`id`, `uuid`, `…Id(s)`, `…Uuid(s)`) that are
|
|
76
|
+
read without `UuidPipe` (`@Param('uuid', UuidPipe)`) or `uuidField` / `optionalUuidField` /
|
|
77
|
+
`uuidArrayField` from `@astram/guard`. The database matches uuids ignoring case, trailing spaces and
|
|
78
|
+
character width, so a raw one would slip past exact checks in your code.
|
|
79
|
+
|
|
70
80
|
## The frontend (`--with-frontend` / `astram-add-frontend`)
|
|
71
81
|
|
|
72
82
|
A Vite + React + shadcn/ui app whose views you own and edit; the auth and API logic comes from
|
|
@@ -75,7 +85,14 @@ A Vite + React + shadcn/ui app whose views you own and edit; the auth and API lo
|
|
|
75
85
|
|
|
76
86
|
- **Sign-in flows:** login, MFA challenge (TOTP or a recovery code), forced MFA re-enrolment
|
|
77
87
|
after a recovery-code login, forgot password, reset password.
|
|
78
|
-
- **Account:** profile, change password, two-factor devices and recovery codes.
|
|
88
|
+
- **Account:** profile, change password, two-factor devices and recovery codes. Changing the
|
|
89
|
+
email asks for the current password (and an authenticator or recovery code when MFA is on);
|
|
90
|
+
the server mails a link to the new address and the email changes only when it is opened.
|
|
91
|
+
- **Email confirmation:** the public `/confirm-email` page opens that link
|
|
92
|
+
(`/confirm-email#t=…`). The token is in the URL fragment; the page removes it from the address
|
|
93
|
+
bar and history at once, sets `Referrer-Policy: no-referrer` (a `<meta>` tag while mounted, and
|
|
94
|
+
a header from `npm run dev` / `npm run preview`), and posts it once. In production, also send
|
|
95
|
+
`Referrer-Policy: no-referrer` for `/confirm-email` from whatever serves the built frontend.
|
|
79
96
|
- **Administration** (shown only to users whose roles grant it): companies, users (memberships
|
|
80
97
|
and role postings), roles, access (role grants and access bundles), record policies (guided rule
|
|
81
98
|
builder), service accounts (roles, scope, API keys with an explicit expiry), cross-tenant grants
|
|
@@ -96,7 +113,6 @@ Redis). Its header comment shows the full wiring. In short:
|
|
|
96
113
|
|
|
97
114
|
```ts
|
|
98
115
|
const rootIds: string[] = [/* platform-admin user uuids, read from your config */];
|
|
99
|
-
const authzPool = createIdentityAuthzPool(); // from @astram/identity/internal; never export it
|
|
100
116
|
|
|
101
117
|
imports: [
|
|
102
118
|
ShieldModule.forRoot({
|
|
@@ -105,12 +121,15 @@ imports: [
|
|
|
105
121
|
rootIds,
|
|
106
122
|
identityActionKeys: identityAuthActionKeys,
|
|
107
123
|
identityControlPlaneKeys,
|
|
108
|
-
recordAuthorization: buildIdentityRecordAuthorization(authzPool, [/* YourType */]),
|
|
109
124
|
}),
|
|
110
125
|
IdentityFeatureModule.forRoot({ rootIds, extraObjects: [/* YourType */] }),
|
|
111
126
|
],
|
|
112
127
|
```
|
|
113
128
|
|
|
129
|
+
`IdentityFeatureModule` builds the record authorization from its `extraObjects` and `ShieldModule`
|
|
130
|
+
decides with it, so your own object types are listed once. Do not pass `recordAuthorization` or
|
|
131
|
+
`decisionAuthority` to `ShieldModule` next to it: boot fails.
|
|
132
|
+
|
|
114
133
|
## Production requirements
|
|
115
134
|
|
|
116
135
|
With `auth: true`, check every item before the first production deploy.
|
|
@@ -134,12 +153,32 @@ With `auth: true`, check every item before the first production deploy.
|
|
|
134
153
|
check your production ACL).
|
|
135
154
|
- **A custom `credentialResolver`** (`ShieldModule.forRoot({ credentialResolver })`) replaces the
|
|
136
155
|
framework's resolver, and with it three login checks that you then own:
|
|
137
|
-
- **MFA enforcement:** return `requiredFactors: ['totp']` for every user
|
|
138
|
-
device
|
|
156
|
+
- **MFA enforcement:** return `requiredFactors: [{ factor: 'totp', secret }]` for every user
|
|
157
|
+
with a confirmed MFA device, where `secret` is the device's TOTP shared secret (several
|
|
158
|
+
devices: one entry, secrets joined with `\n`, as the built-in resolver does). A plain
|
|
159
|
+
`['totp']` does not type-check and leaves the user stuck at `mfa_required`. Return `[]` (or
|
|
160
|
+
omit the field) and that user signs in with the password alone.
|
|
139
161
|
- **Disabled accounts:** return `null` for an account that must not sign in (the built-in
|
|
140
162
|
resolver does this for `shield_app.users.u_status` other than `active`).
|
|
141
163
|
- **Lockout keying:** the per-account lockout is keyed on the `id` you return, so return the
|
|
142
164
|
same stable id for every spelling of the user's login hint.
|
|
165
|
+
```ts
|
|
166
|
+
import type { CredentialResolver, ResolvedCredential } from '@astram/guard';
|
|
167
|
+
|
|
168
|
+
export class DirectoryCredentialResolver implements CredentialResolver {
|
|
169
|
+
async resolve(identityHint: string): Promise<ResolvedCredential | null> {
|
|
170
|
+
const user = await directory.findByLogin(identityHint.trim().toLowerCase());
|
|
171
|
+
if (!user || user.status !== 'active') return null; // unknown or disabled: same answer
|
|
172
|
+
const secrets = await directory.confirmedTotpSecrets(user.id);
|
|
173
|
+
return {
|
|
174
|
+
passwordHash: user.passwordHash,
|
|
175
|
+
claims: { id: user.id, tenantId: user.companyId, roleIds: user.roleIds }, // stable id
|
|
176
|
+
requiredFactors: secrets.length ? [{ factor: 'totp', secret: secrets.join('\n') }] : [],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
143
182
|
The authenticator's other protections (decoy verify, no account enumeration, no secret leaks,
|
|
144
183
|
TOTP limiter and replay guard) still apply.
|
|
145
184
|
- **`rootIds`.** Pass the same platform-administrator user uuids to `ShieldModule.forRoot` and
|
|
@@ -166,6 +205,116 @@ With `auth: true`, check every item before the first production deploy.
|
|
|
166
205
|
in-flight `/auth/refresh` per session). Concurrent refreshes of the same token can trip reuse
|
|
167
206
|
detection, which revokes the session (the user must sign in again).
|
|
168
207
|
|
|
208
|
+
## Upgrading from 0.8.x
|
|
209
|
+
|
|
210
|
+
create-astram-app 0.9.0 adds one lint rule (KI #82) and the verified email change (KI #14).
|
|
211
|
+
Bump the ranges in `server/package.json` to `@astram/identity` ^0.8.0, `@astram/guard` ^0.11.0,
|
|
212
|
+
`@astram/engine` ^0.5.2 and `@astram/guide` ^0.2.3, and `frontend/package.json` to
|
|
213
|
+
`@astram/frontend-core` ^0.6.0, then `npm install` in both and run the migrations (identity
|
|
214
|
+
`1710000000020`) with the DDL account before starting the new version.
|
|
215
|
+
|
|
216
|
+
**Lint rule (KI #82):** copy `server/eslint.config.mjs` from a fresh scaffold, run `npm run lint`,
|
|
217
|
+
and put every flagged uuid input through `UuidPipe` (route params) or `uuidField` /
|
|
218
|
+
`optionalUuidField` / `uuidArrayField` (body and query fields).
|
|
219
|
+
|
|
220
|
+
**Verified email change (KI #14) — BREAKING in `@astram/guard` 0.11, `@astram/identity` 0.8:**
|
|
221
|
+
|
|
222
|
+
| Change | What you do |
|
|
223
|
+
|---|---|
|
|
224
|
+
| **BREAKING:** `PATCH /account/profile` with an `email` needs `currentPassword` (and `code` — TOTP or a recovery code — when the user has MFA) and answers **202** `{ pendingEmail: true, profile }` instead of 200. The address changes only after the user opens the link sent to the NEW address. A name-only edit still answers 200. | Send `currentPassword` (+ `code`) with an email change and treat 202 as "check your new inbox". Errors: 400 `invalid_current_password` / `mfa_required` / `invalid_request`, 422 `invalid_email` / `no_changes`, 429 `too_many_requests`, 503 `authentication_unavailable`. |
|
|
225
|
+
| **BREAKING:** adding an MFA device when one is already confirmed needs `code`: `POST /account/mfa/devices/begin` `{ name?, code }` takes a TOTP from an existing device or a recovery code; 400 `mfa_required` without it, 400 `invalid_request` when wrong, 429 / 503. A recovery-login re-enroll session still adds a device without a code. With MFA on, confirm accepts only a device begun in the last 15 minutes (404 otherwise). | Ask for a current code on the add-device form when the user already has a device and send it with `accountService.beginMfaDevice(token, name, code)` (`reason: 'mfa_required'` → ask for it). Scaffolded apps: copy `frontend/src/pages/account/TwoFactorPage.jsx` and `frontend/src/auth/deviceEnrollment.js` (+ its `*.test.mjs`) from a fresh scaffold. |
|
|
226
|
+
| New route `POST /auth/confirm-email` `{ token }` (public): 200 `{ ok: true }`, 400 `invalid_token`, 409 `email_unavailable`, 429, 503. On success every other session of the user is signed out and the old address gets a notice. | Add a confirm page that reads the token from the URL fragment (`#t=…`), POSTs it, and sets `Referrer-Policy: no-referrer`. |
|
|
227
|
+
| New option `ShieldModule.forRoot({ emailChangeLinkBaseUrl })`, default `http://localhost:5173/confirm-email`. The mailed link is `${emailChangeLinkBaseUrl}#t=<token>`. | Set it to your confirm page URL in every non-local environment. |
|
|
228
|
+
| Identity migration `1710000000020-CreateEmailChangeToken` (`shield_identity.email_change_token`, FK to `shield_app.users`). | Run the migrations (DDL account) before starting the new version. |
|
|
229
|
+
| A password reset or change cancels a pending email change. An admin email change (`PUT /identity/users/:uuid`) refuses an address another user or login already has (409), rejects a malformed one (400), and moves the login key when it equalled the old email. | Nothing, unless your admin UI sends an empty email to clear it (now 400). |
|
|
230
|
+
|
|
231
|
+
**Frontend for the email change:** `PATCH /account/profile` with an `email` now needs
|
|
232
|
+
`currentPassword` (plus `code` when MFA is enrolled) and answers 202; the email changes only when
|
|
233
|
+
the user opens the link mailed to the new address. An app scaffolded earlier still saves names,
|
|
234
|
+
but its profile page cannot change the email. Update `@astram/frontend-core` to 0.6 and copy from
|
|
235
|
+
a fresh scaffold: `frontend/src/pages/account/ProfilePage.jsx`, `frontend/src/pages/ConfirmEmail.jsx`,
|
|
236
|
+
`frontend/src/auth/profileEdit.js`, `frontend/src/auth/confirmEmailLink.js` (and their
|
|
237
|
+
`*.test.mjs`), the `/confirm-email` route in `frontend/src/App.jsx`, and the
|
|
238
|
+
`confirmEmailNoReferrer` plugin in `frontend/vite.config.js`. If you build your own UI, use
|
|
239
|
+
`accountService.updateProfile(token, { email, currentPassword, code })` (202 →
|
|
240
|
+
`{ ok: true, pendingEmail: true }`, `reason: 'mfa_required'` → ask for a code) and
|
|
241
|
+
`authService.confirmEmail(token)`.
|
|
242
|
+
|
|
243
|
+
## Upgrading from 0.7.x
|
|
244
|
+
|
|
245
|
+
create-astram-app 0.8 pairs with `@astram/identity` 0.7 and `@astram/guard` 0.10 (breaking wiring
|
|
246
|
+
change). `IdentityFeatureModule` now builds the record authorization itself, from its own
|
|
247
|
+
`extraObjects`, and `ShieldModule` decides with it. Before, the app built it separately with
|
|
248
|
+
`buildIdentityRecordAuthorization(authzPool, extraObjects)`, and an app could pass different object
|
|
249
|
+
types to the two calls, or skip the second call and get the deny-everything authority.
|
|
250
|
+
|
|
251
|
+
**1. Bump the ranges** in `server/package.json` to `@astram/identity` ^0.7.0, `@astram/guard`
|
|
252
|
+
^0.10.0, `@astram/engine` ^0.5.1, `@astram/migrate` ^0.3.2 and `@astram/guide` ^0.2.2, and
|
|
253
|
+
`frontend/package.json` to `@astram/frontend-core` ^0.5.2, then `npm install` in both. Every package now requires `mariadb` ^3.5.4 (driver SQL-injection advisories). No new
|
|
254
|
+
migrations.
|
|
255
|
+
|
|
256
|
+
**2. Change `server/src/app.module.ts`:**
|
|
257
|
+
|
|
258
|
+
- Delete `recordAuthorization: buildIdentityRecordAuthorization(...)` from `ShieldModule.forRoot`
|
|
259
|
+
(and from a `forRootAsync` factory). `buildIdentityRecordAuthorization` no longer exists.
|
|
260
|
+
- Pass your own object types only to `IdentityFeatureModule.forRoot({ extraObjects })`.
|
|
261
|
+
- Delete `createIdentityAuthzPool()` and its import from `@astram/identity/internal` unless another
|
|
262
|
+
wiring line still uses the pool (for example `new DbScopeSubtreeResolver(authzPool)`).
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
// before
|
|
266
|
+
const authzPool = createIdentityAuthzPool();
|
|
267
|
+
ShieldModule.forRoot({ auth: true, /* … */, recordAuthorization: buildIdentityRecordAuthorization(authzPool, [Note]) }),
|
|
268
|
+
IdentityFeatureModule.forRoot({ rootIds, extraObjects: [Note] }),
|
|
269
|
+
// after
|
|
270
|
+
ShieldModule.forRoot({ auth: true, /* … */ }),
|
|
271
|
+
IdentityFeatureModule.forRoot({ rootIds, extraObjects: [Note] }),
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**Boot checks:** `ShieldModule` fails the boot if it receives `recordAuthorization` or
|
|
275
|
+
`decisionAuthority` while `IdentityFeatureModule` is imported (two sources), and
|
|
276
|
+
`IdentityFeatureModule` fails the boot if the request path would use the deny-everything authority
|
|
277
|
+
(for example with an older `@astram/guard`).
|
|
278
|
+
|
|
279
|
+
**Behaviour changes:** your `extraObjects` types are now also known to cross-tenant grants (offerable
|
|
280
|
+
types, the offer/accept check) and to the guided rule-builder's policy check. The identity module
|
|
281
|
+
opens one pool fewer (the request path uses the identity module's authz pool).
|
|
282
|
+
|
|
283
|
+
**3. Copy the lint-wall files** from a fresh scaffold: `server/eslint.config.mjs`, the new
|
|
284
|
+
`server/scripts/check-wall.mjs`, and the `lint` and `lint:wall` scripts in `server/package.json`.
|
|
285
|
+
The wall now also rejects any `node_modules/` or `@astram/<pkg>/dist|src` deep import, banned names
|
|
286
|
+
imported under an alias, computed `import()`/`require()` specifiers, and the driver methods
|
|
287
|
+
`execute`, `batch`, `queryStream` and `beginTransaction` (so `CommandBus.execute()` from
|
|
288
|
+
`@nestjs/cqrs` is flagged too). `lint:wall` fails on any `/internal` or `node_modules/@astram`
|
|
289
|
+
import under `src/features`; run it in CI. The wall catches accidental use; deliberate bypass
|
|
290
|
+
(`.call`, `Reflect.apply`, computed method names, an aliased `require`) is a code-review matter.
|
|
291
|
+
|
|
292
|
+
**More behaviour changes:**
|
|
293
|
+
|
|
294
|
+
- Change-password answers a wrong current password with 400 `invalid_current_password` (was 401)
|
|
295
|
+
and a locked account with 429 (was 401). The frontend shows clear messages for 429 and 503.
|
|
296
|
+
- Login, `/auth/mfa`, change-password and the code-gated MFA account routes count each attempt
|
|
297
|
+
before verifying it (parallel guesses can no longer slip past the limit) and answer 503 when the
|
|
298
|
+
limiter's Redis call fails (before: login let the attempt through). A refused attempt counts too.
|
|
299
|
+
An app that overrides a limiter token with its own object must implement `consume` and `refund`.
|
|
300
|
+
- A role delete the engine refuses no longer strips the role's grants. Revoking a grant needs
|
|
301
|
+
authority over every service account holding the role (403 otherwise).
|
|
302
|
+
- Renaming another user needs authority over that user. A new posting's department and branch must
|
|
303
|
+
be linked to the membership's company (400 otherwise). A non-root admin cannot change the status
|
|
304
|
+
of their own root company (403).
|
|
305
|
+
|
|
306
|
+
**4. Check for agent keys that never expire.** Keys issued before an expiry became mandatory
|
|
307
|
+
(identity < 0.5) have none. `@astram/identity` 0.7 ships a read-only report; run it in `server/`
|
|
308
|
+
with the app's `APP_DB_*` variables set:
|
|
309
|
+
|
|
310
|
+
```sh
|
|
311
|
+
npx astram-sa-keys-report # lists agent label, company, key version, issue date
|
|
312
|
+
npx astram-sa-keys-report --fail-on-found # exit 1 if any are listed (CI)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Rotate each listed agent's key with an expiry in the service-accounts page. The report never prints
|
|
316
|
+
secrets, hashes or ids.
|
|
317
|
+
|
|
169
318
|
## Upgrading from 0.6.x
|
|
170
319
|
|
|
171
320
|
create-astram-app 0.7 (2026-10-09) pairs with a framework release that moves raw pools off the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Scaffolder for a new Astram app — emits a running, framework-enforced NestJS server (deny-by-default, built-in auth/identity to switch on) and, with --with-frontend, a React admin/auth UI, over the immutable @astram/* framework packages.",
|
|
6
6
|
"type": "module",
|
package/template/CLAUDE.md
CHANGED
|
@@ -11,8 +11,8 @@ The Astram framework ships its hard rules + capabilities in the installed
|
|
|
11
11
|
copies — they are regenerated from the framework package on every install/upgrade):
|
|
12
12
|
|
|
13
13
|
- **`.claude/rules/astram-framework-rules.md`** — the non-negotiable rules: never edit
|
|
14
|
-
the framework, all data through the RecordEngine pipe, classify every route,
|
|
15
|
-
**STOP-and-flag a gap** instead of working around it, fail closed, and — after any
|
|
14
|
+
the framework, all data through the RecordEngine pipe, classify every route, validate
|
|
15
|
+
uuid inputs with `UuidPipe` / `uuidField`, **STOP-and-flag a gap** instead of working around it, fail closed, and — after any
|
|
16
16
|
framework upgrade — **re-read these rules first** (Rule 0).
|
|
17
17
|
- **`.claude/rules/astram-framework-capabilities.md`** — what the framework provides
|
|
18
18
|
this version (check here before building; if it's not there, it's a gap → flag it).
|
package/template/README.md
CHANGED
|
@@ -70,9 +70,10 @@ boot-pool assertion (ADR-0018), and the Core decision-authority token. With no o
|
|
|
70
70
|
needs no database or Redis. To turn on login, sessions, MFA and the identity admin
|
|
71
71
|
(`/auth/*`, `/account/*`, `/identity/*`), follow the commented wiring in the header of
|
|
72
72
|
`server/src/app.module.ts`: `ShieldModule.forRoot({ auth: true, emailSender, rootIds,
|
|
73
|
-
identityActionKeys, identityControlPlaneKeys
|
|
74
|
-
`IdentityFeatureModule.forRoot({ rootIds, extraObjects })
|
|
75
|
-
|
|
73
|
+
identityActionKeys, identityControlPlaneKeys })` plus
|
|
74
|
+
`IdentityFeatureModule.forRoot({ rootIds, extraObjects })`, which builds the record
|
|
75
|
+
authorization `ShieldModule` decides with (pass no `recordAuthorization`). The framework provides
|
|
76
|
+
the credential store, MFA, session handling and the decision authority; you write none of them.
|
|
76
77
|
|
|
77
78
|
### Before you deploy with auth on
|
|
78
79
|
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* /login, /forgot-password → public auth screens (redirect to /dashboard if
|
|
5
5
|
* already signed in)
|
|
6
|
-
* /
|
|
6
|
+
* /confirm-email → public for everyone (the email-change link, KI #14)
|
|
7
|
+
* /dashboard → protected (RequireAuth bounces to /login, saving the
|
|
7
8
|
* return path)
|
|
8
9
|
*/
|
|
9
10
|
|
|
@@ -16,6 +17,8 @@ import { decideRoute, REENROLL_PATH } from './auth/routeGate';
|
|
|
16
17
|
import Login from './pages/Login';
|
|
17
18
|
import ForgotPassword from './pages/ForgotPassword';
|
|
18
19
|
import ResetPassword from './pages/ResetPassword';
|
|
20
|
+
import ConfirmEmail from './pages/ConfirmEmail';
|
|
21
|
+
import { CONFIRM_EMAIL_PATH } from './auth/confirmEmailLink';
|
|
19
22
|
import Dashboard from './pages/Dashboard';
|
|
20
23
|
import AccountLayout from './pages/account/AccountLayout';
|
|
21
24
|
import ProfilePage from './pages/account/ProfilePage';
|
|
@@ -137,6 +140,9 @@ export default function App() {
|
|
|
137
140
|
</PublicOnly>
|
|
138
141
|
}
|
|
139
142
|
/>
|
|
143
|
+
{/* Email-change confirmation (KI #14): public for everyone, signed in or not. NOT
|
|
144
|
+
PublicOnly — bouncing a signed-in user to /dashboard would drop the `#t=` token. */}
|
|
145
|
+
<Route path={CONFIRM_EMAIL_PATH} element={<ConfirmEmail />} />
|
|
140
146
|
<Route
|
|
141
147
|
path="/dashboard"
|
|
142
148
|
element={
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The email-change confirmation link, kept free of React so `node --test` can check it (KI #14).
|
|
3
|
+
*
|
|
4
|
+
* The server mails `<origin>/confirm-email#t=<token>` to the NEW address. The token sits in the
|
|
5
|
+
* URL FRAGMENT, which browsers never send to a server or put in a Referer header. The page
|
|
6
|
+
* still removes it from the address bar and history the moment it has read it, and sets
|
|
7
|
+
* `Referrer-Policy: no-referrer` while it is shown. Never log, store or put the token in a query.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The public route the link opens. */
|
|
11
|
+
export const CONFIRM_EMAIL_PATH = '/confirm-email';
|
|
12
|
+
|
|
13
|
+
// url-safe token alphabet (base64url / hex); anything else is not a link the server minted.
|
|
14
|
+
const TOKEN_SHAPE = /^[A-Za-z0-9_-]{1,512}$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `t` value of a location fragment (`#t=…`), or '' when missing or malformed.
|
|
18
|
+
* @param {string | null | undefined} hash `window.location.hash`
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function readConfirmToken(hash) {
|
|
22
|
+
if (typeof hash !== 'string' || hash.length < 2) return '';
|
|
23
|
+
const token = new URLSearchParams(hash.replace(/^#/, '')).get('t') ?? '';
|
|
24
|
+
return TOKEN_SHAPE.test(token) ? token : '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Drop the fragment from the CURRENT history entry (no new entry, router state kept), so the
|
|
29
|
+
* token does not stay in the address bar, the back/forward list or a bookmark.
|
|
30
|
+
* @param {{ location: { pathname: string, search: string, hash: string },
|
|
31
|
+
* history: { state: unknown, replaceState: Function } }} win `window`
|
|
32
|
+
* @returns {boolean} true when a fragment was removed
|
|
33
|
+
*/
|
|
34
|
+
export function scrubConfirmToken(win) {
|
|
35
|
+
if (!win.location.hash) return false;
|
|
36
|
+
win.history.replaceState(win.history.state, '', win.location.pathname + win.location.search);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Add `<meta name="referrer" content="no-referrer">` for as long as the page is mounted.
|
|
42
|
+
* @param {Document} doc
|
|
43
|
+
* @returns {() => void} removes the tag again
|
|
44
|
+
*/
|
|
45
|
+
export function applyNoReferrer(doc) {
|
|
46
|
+
const meta = doc.createElement('meta');
|
|
47
|
+
meta.setAttribute('name', 'referrer');
|
|
48
|
+
meta.setAttribute('content', 'no-referrer');
|
|
49
|
+
doc.head.appendChild(meta);
|
|
50
|
+
return () => doc.head.removeChild(meta);
|
|
51
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// confirmEmailLink.test.mjs — KI #14: the email-change link carries its single-use token in
|
|
2
|
+
// the URL FRAGMENT (`/confirm-email#t=…`) so it never reaches a server log or a Referer. The
|
|
3
|
+
// page must read it, then drop it from the address bar and history at once.
|
|
4
|
+
//
|
|
5
|
+
// node --test src/auth/confirmEmailLink.test.mjs
|
|
6
|
+
import { test, describe } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import {
|
|
9
|
+
CONFIRM_EMAIL_PATH,
|
|
10
|
+
readConfirmToken,
|
|
11
|
+
scrubConfirmToken,
|
|
12
|
+
applyNoReferrer,
|
|
13
|
+
} from './confirmEmailLink.js';
|
|
14
|
+
|
|
15
|
+
const TOKEN = ['placeholder', 'link', 'value'].join('_');
|
|
16
|
+
|
|
17
|
+
describe('readConfirmToken', () => {
|
|
18
|
+
test('reads t from the fragment', () => {
|
|
19
|
+
assert.equal(readConfirmToken(`#t=${TOKEN}`), TOKEN);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('ignores other fragment keys and order', () => {
|
|
23
|
+
assert.equal(readConfirmToken(`#x=1&t=${TOKEN}`), TOKEN);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('missing, empty or no fragment → empty string', () => {
|
|
27
|
+
for (const hash of ['', '#', '#t=', '#token=abc', undefined, null]) {
|
|
28
|
+
assert.equal(readConfirmToken(hash), '');
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('a token with characters outside the url-safe set is refused', () => {
|
|
33
|
+
assert.equal(readConfirmToken('#t=abc%20def'), '');
|
|
34
|
+
assert.equal(readConfirmToken('#t=<script>'), '');
|
|
35
|
+
assert.equal(readConfirmToken(`#t=${'a'.repeat(600)}`), '');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('the page path is /confirm-email', () => {
|
|
39
|
+
assert.equal(CONFIRM_EMAIL_PATH, '/confirm-email');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('scrubConfirmToken', () => {
|
|
44
|
+
function fakeWindow(url) {
|
|
45
|
+
const u = new URL(url);
|
|
46
|
+
const calls = [];
|
|
47
|
+
return {
|
|
48
|
+
calls,
|
|
49
|
+
location: { pathname: u.pathname, search: u.search, hash: u.hash },
|
|
50
|
+
history: {
|
|
51
|
+
state: { idx: 3, key: 'k' },
|
|
52
|
+
replaceState: (state, title, next) => calls.push({ state, title, next }),
|
|
53
|
+
pushState: () => assert.fail('must not add a history entry'),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('replaces the current entry with the same path minus the fragment, keeping router state', () => {
|
|
59
|
+
const win = fakeWindow(`http://app.test/confirm-email?x=1#t=${TOKEN}`);
|
|
60
|
+
assert.equal(scrubConfirmToken(win), true);
|
|
61
|
+
assert.equal(win.calls.length, 1);
|
|
62
|
+
assert.equal(win.calls[0].next, '/confirm-email?x=1');
|
|
63
|
+
assert.deepEqual(win.calls[0].state, { idx: 3, key: 'k' });
|
|
64
|
+
assert.ok(!JSON.stringify(win.calls).includes(TOKEN));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('never moves the token into the query string', () => {
|
|
68
|
+
const win = fakeWindow(`http://app.test/confirm-email#t=${TOKEN}`);
|
|
69
|
+
scrubConfirmToken(win);
|
|
70
|
+
assert.equal(win.calls[0].next, '/confirm-email');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('nothing to scrub → no history call', () => {
|
|
74
|
+
const win = fakeWindow('http://app.test/confirm-email');
|
|
75
|
+
assert.equal(scrubConfirmToken(win), false);
|
|
76
|
+
assert.equal(win.calls.length, 0);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('applyNoReferrer', () => {
|
|
81
|
+
function fakeDocument() {
|
|
82
|
+
const children = [];
|
|
83
|
+
return {
|
|
84
|
+
children,
|
|
85
|
+
head: {
|
|
86
|
+
appendChild: (el) => children.push(el),
|
|
87
|
+
removeChild: (el) => children.splice(children.indexOf(el), 1),
|
|
88
|
+
},
|
|
89
|
+
createElement: (tag) => ({
|
|
90
|
+
tag,
|
|
91
|
+
attrs: {},
|
|
92
|
+
setAttribute(k, v) {
|
|
93
|
+
this.attrs[k] = v;
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('adds <meta name="referrer" content="no-referrer"> and the cleanup removes it', () => {
|
|
100
|
+
const doc = fakeDocument();
|
|
101
|
+
const cleanup = applyNoReferrer(doc);
|
|
102
|
+
assert.equal(doc.children.length, 1);
|
|
103
|
+
assert.equal(doc.children[0].tag, 'meta');
|
|
104
|
+
assert.deepEqual(doc.children[0].attrs, { name: 'referrer', content: 'no-referrer' });
|
|
105
|
+
cleanup();
|
|
106
|
+
assert.equal(doc.children.length, 0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adding an authenticator device, kept free of React so `node --test` can check it.
|
|
3
|
+
*
|
|
4
|
+
* When the account already has a confirmed device, the server needs a code from one of them (or
|
|
5
|
+
* a recovery code) before it starts enrolling another, so a stolen session alone cannot add the
|
|
6
|
+
* attacker's app (KI #14 re-review). The recovery-login re-enroll screen is exempt: that user
|
|
7
|
+
* signed in with a recovery code because the device is lost. The code is sent once, never stored.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {{ deviceCount: number, reenrollMode: boolean, serverAsked?: boolean }} state
|
|
12
|
+
* @returns {boolean} whether the add-device form must ask for a code
|
|
13
|
+
*/
|
|
14
|
+
export function addDeviceNeedsCode({ deviceCount, reenrollMode, serverAsked = false }) {
|
|
15
|
+
if (serverAsked) return true;
|
|
16
|
+
return deviceCount > 0 && !reenrollMode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Turn the add-device form into the begin arguments, or an error to show before sending.
|
|
21
|
+
* @param {{ name: string, code: string, codeRequired: boolean }} form
|
|
22
|
+
* @returns {{ error: string } | { name: string, code?: string }}
|
|
23
|
+
*/
|
|
24
|
+
export function planBeginDevice(form) {
|
|
25
|
+
const name = form.name.trim();
|
|
26
|
+
if (!name) return { error: 'Enter a name for this device.' };
|
|
27
|
+
if (!form.codeRequired) return { name };
|
|
28
|
+
const code = form.code.trim();
|
|
29
|
+
if (!code) {
|
|
30
|
+
return { error: 'Enter a code from one of your authenticator apps or a recovery code.' };
|
|
31
|
+
}
|
|
32
|
+
return { name, code };
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// deviceEnrollment.test.mjs — KI #14 re-review: adding an authenticator when the account already
|
|
2
|
+
// has one needs a code from an existing device (or a recovery code); the recovery-login re-enroll
|
|
3
|
+
// screen is exempt.
|
|
4
|
+
//
|
|
5
|
+
// node --test src/auth/deviceEnrollment.test.mjs
|
|
6
|
+
import { test, describe } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { addDeviceNeedsCode, planBeginDevice } from './deviceEnrollment.js';
|
|
9
|
+
|
|
10
|
+
describe('addDeviceNeedsCode', () => {
|
|
11
|
+
test('no device yet → no code', () => {
|
|
12
|
+
assert.equal(addDeviceNeedsCode({ deviceCount: 0, reenrollMode: false }), false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('MFA already on → a code', () => {
|
|
16
|
+
assert.equal(addDeviceNeedsCode({ deviceCount: 1, reenrollMode: false }), true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('re-enroll screen (lost device still listed) → no code', () => {
|
|
20
|
+
assert.equal(addDeviceNeedsCode({ deviceCount: 1, reenrollMode: true }), false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('the server asked for one (mfa_required) → a code, even if the list was empty', () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
addDeviceNeedsCode({ deviceCount: 0, reenrollMode: false, serverAsked: true }),
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('planBeginDevice', () => {
|
|
32
|
+
test('a name is required', () => {
|
|
33
|
+
assert.deepEqual(planBeginDevice({ name: ' ', code: '', codeRequired: false }), {
|
|
34
|
+
error: 'Enter a name for this device.',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('no code needed → only the name', () => {
|
|
39
|
+
assert.deepEqual(planBeginDevice({ name: ' Phone ', code: '123', codeRequired: false }), {
|
|
40
|
+
name: 'Phone',
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('code needed but empty → error, nothing sent', () => {
|
|
45
|
+
assert.deepEqual(planBeginDevice({ name: 'Tablet', code: ' ', codeRequired: true }), {
|
|
46
|
+
error: 'Enter a code from one of your authenticator apps or a recovery code.',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('code needed → name + trimmed code', () => {
|
|
51
|
+
assert.deepEqual(planBeginDevice({ name: 'Tablet', code: ' 123456 ', codeRequired: true }), {
|
|
52
|
+
name: 'Tablet',
|
|
53
|
+
code: '123456',
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The self-service profile edit, kept free of React so `node --test` can check it (KI #14).
|
|
3
|
+
*
|
|
4
|
+
* A name change applies at once. An email change is VERIFIED by the server: it needs the
|
|
5
|
+
* current password (plus a TOTP or recovery code when the account has MFA) and only takes
|
|
6
|
+
* effect when the user opens the link mailed to the NEW address. Until then the profile keeps
|
|
7
|
+
* the old email. The password and code are sent with this one request and never stored.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const PENDING_EMAIL_MESSAGE =
|
|
11
|
+
'We sent a confirmation link to the new address. Your email changes when you open it.';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Turn the form into the PATCH body, or an error to show before sending anything.
|
|
15
|
+
* @param {{ name: string, email: string, currentPassword: string, code: string,
|
|
16
|
+
* codeRequired: boolean }} form
|
|
17
|
+
* @param {{ name: string, email: string }} initial the current profile values
|
|
18
|
+
* @returns {{ error: string } | { changes: object, emailChanging: boolean }}
|
|
19
|
+
*/
|
|
20
|
+
export function planProfileEdit(form, initial) {
|
|
21
|
+
const name = form.name.trim();
|
|
22
|
+
const email = form.email.trim();
|
|
23
|
+
if (!name) return { error: 'Please enter your name.' };
|
|
24
|
+
|
|
25
|
+
const changes = {};
|
|
26
|
+
if (name !== initial.name) changes.name = name;
|
|
27
|
+
const emailChanging = email !== initial.email;
|
|
28
|
+
if (emailChanging) {
|
|
29
|
+
// The password is sent as typed (spaces are part of it).
|
|
30
|
+
if (!form.currentPassword) {
|
|
31
|
+
return { error: 'Enter your current password to change your email.' };
|
|
32
|
+
}
|
|
33
|
+
const code = form.code.trim();
|
|
34
|
+
if (form.codeRequired && !code) {
|
|
35
|
+
return { error: 'Enter a code from your authenticator app or a recovery code.' };
|
|
36
|
+
}
|
|
37
|
+
changes.email = email;
|
|
38
|
+
changes.currentPassword = form.currentPassword;
|
|
39
|
+
if (code) changes.code = code;
|
|
40
|
+
}
|
|
41
|
+
if (Object.keys(changes).length === 0) return { error: 'Nothing to update.' };
|
|
42
|
+
return { changes, emailChanging };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What the page does with an `accountService.updateProfile` result.
|
|
47
|
+
* @returns {{ kind: 'saved' } | { kind: 'pending', message: string }
|
|
48
|
+
* | { kind: 'error', error: string, showCode: boolean }}
|
|
49
|
+
*/
|
|
50
|
+
export function interpretProfileResult(r) {
|
|
51
|
+
if (r.ok && r.pendingEmail) return { kind: 'pending', message: PENDING_EMAIL_MESSAGE };
|
|
52
|
+
if (r.ok) return { kind: 'saved' };
|
|
53
|
+
return {
|
|
54
|
+
kind: 'error',
|
|
55
|
+
error: r.error || 'Could not update your profile.',
|
|
56
|
+
showCode: r.reason === 'mfa_required' || r.reason === 'invalid_code',
|
|
57
|
+
};
|
|
58
|
+
}
|