pgkiln 0.64.0 → 0.65.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.
Files changed (44) hide show
  1. package/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
  2. package/docs/apex-feature-parity.md +5 -3
  3. package/docs/guide/08-security.md +41 -6
  4. package/docs/guide/12-development.md +10 -4
  5. package/docs/guide/13-rest-api.md +4 -0
  6. package/package.json +1 -1
  7. package/public/app.js +63 -0
  8. package/public/builder.js +1 -62
  9. package/src/account-mfa.ts +94 -3
  10. package/src/api.ts +9 -2
  11. package/src/builder/api.ts +3 -1
  12. package/src/builder/components.ts +1 -0
  13. package/src/builder/login-page.ts +10 -5
  14. package/src/builder/rest-modules.ts +1 -1
  15. package/src/builder/security.ts +3 -19
  16. package/src/builder/shared.ts +5 -3
  17. package/src/i18n/ar.ts +31 -0
  18. package/src/i18n/cs.ts +31 -0
  19. package/src/i18n/da.ts +31 -0
  20. package/src/i18n/de.ts +31 -0
  21. package/src/i18n/el.ts +31 -0
  22. package/src/i18n/es.ts +31 -0
  23. package/src/i18n/fi.ts +31 -0
  24. package/src/i18n/fr.ts +31 -0
  25. package/src/i18n/he.ts +31 -0
  26. package/src/i18n/it.ts +31 -0
  27. package/src/i18n/ja.ts +31 -0
  28. package/src/i18n/ko.ts +31 -0
  29. package/src/i18n/nb.ts +31 -0
  30. package/src/i18n/pl.ts +31 -0
  31. package/src/i18n/pt.ts +31 -0
  32. package/src/i18n/ru.ts +31 -0
  33. package/src/i18n/sv.ts +31 -0
  34. package/src/i18n/tr.ts +31 -0
  35. package/src/i18n/uk.ts +31 -0
  36. package/src/i18n/zh.ts +31 -0
  37. package/src/i18n.ts +62 -0
  38. package/src/metadata.ts +8 -2
  39. package/src/mfa.ts +18 -13
  40. package/src/runtime/account.ts +6 -3
  41. package/src/runtime/authz.ts +23 -4
  42. package/src/runtime/routes.ts +11 -1
  43. package/src/runtime/two-step.ts +379 -72
  44. package/src/webauthn.ts +20 -0
@@ -0,0 +1,54 @@
1
+ -- ---------------------------------------------------------------------
2
+ -- 133: passkeys, e-mail codes and step-up for application users (sprint
3
+ -- 131, SEC-3 part 2; beyond APEX).
4
+ --
5
+ -- meta.account_factor gets a second kind, 'passkey' (WebAuthn), with the
6
+ -- same columns as a developer's (129): the credential id (base64url, unique),
7
+ -- its public key as a JWK, the COSE algorithm, the signature counter, the
8
+ -- relying party id (host name) it was made for and the transports. A passkey
9
+ -- keeps working without the instance key (it is a public key).
10
+ --
11
+ -- meta.app.mfa_email: at the second step, a user may ask for a one-time code
12
+ -- by e-mail (through the application's mail service) instead of opening their
13
+ -- authenticator app. Only users who have an authenticator app or a passkey
14
+ -- are offered it: an e-mail code is never someone's only factor (SEC-6).
15
+ --
16
+ -- meta.authz_scheme.mfa_minutes (step-up): the scheme passes only when the
17
+ -- session's last second step is at most this many minutes old; a page that
18
+ -- needs it asks for a code (or a passkey) again before it opens. null: no
19
+ -- step-up (the default). Exported with the application.
20
+ -- ---------------------------------------------------------------------
21
+ alter table meta.account_factor drop constraint account_factor_kind_check;
22
+ alter table meta.account_factor
23
+ alter column secret_enc drop not null,
24
+ add column credential_id text unique,
25
+ add column public_key jsonb,
26
+ add column alg int,
27
+ add column sign_count bigint not null default 0,
28
+ add column rp_id text,
29
+ add column transports text[] not null default '{}',
30
+ add constraint account_factor_kind_check check (kind in ('totp', 'passkey')),
31
+ add constraint account_factor_kind_fields check (
32
+ case kind
33
+ when 'totp' then secret_enc is not null
34
+ when 'passkey' then credential_id is not null and public_key is not null and alg is not null and rp_id is not null
35
+ end);
36
+ comment on table meta.account_factor is
37
+ 'An application account''s second factors: authenticator apps (TOTP, 132; the secret encrypted with the instance key) and passkeys (WebAuthn, 133; a public key).';
38
+
39
+ alter table meta.app add column mfa_email boolean not null default false;
40
+ comment on column meta.app.mfa_email is
41
+ 'Two-step sign-in (133): users with an authenticator app or a passkey may get a one-time code by e-mail instead.';
42
+
43
+ alter table meta.authz_scheme
44
+ add column mfa_minutes int check (mfa_minutes between 1 and 1440);
45
+ comment on column meta.authz_scheme.mfa_minutes is
46
+ 'Step-up (133): the scheme passes only when the session''s last second step is at most this many minutes old (null: no step-up).';
47
+
48
+ -- meta.app.api_self_tokens: signed-in users create their own REST API token
49
+ -- on My account. A user the application requires a second step of gets one
50
+ -- only after a second step at most 15 minutes old; the builder's "Issue a
51
+ -- token" refuses such accounts (SEC-3: no token around two-step sign-in).
52
+ alter table meta.app add column api_self_tokens boolean not null default false;
53
+ comment on column meta.app.api_self_tokens is
54
+ 'Users create their own REST API token on My account (133); after a recent second step when the app requires one of them.';
@@ -138,7 +138,7 @@ the 26.2 comparison.)
138
138
  | APEX accounts | ✅ | Instance-wide user directory, bcrypt, session rotation, idle and absolute timeouts |
139
139
  | Application Access Control (roles per app, any-user switch) | ✅ | Access control per application |
140
140
  | Social sign-in / OpenID Connect | ✅ | Any OIDC provider (Entra ID, Google, Okta, Keycloak, …): PKCE, group → role mapping, account linking, automatic accounts |
141
- | Multi-factor authentication | ✅ | As in APEX: through the identity provider (OpenID Connect, SAML). Beyond APEX (sprints 126–128): the builder itself has two-factor sign-in with passkeys (WebAuthn, also on their own without a password), authenticator apps (TOTP) and recovery codes, trusted browsers, an instance and per-workspace policy with a grace period and lockout recovery ([chapter 3](guide/03-builder.md#two-factor-sign-in)). Sprint 130: application users too, per application (off, optional, required, required for roles) with authenticator apps and recovery codes, added while signing in or on My account, reset from Users ([chapter 8](guide/08-security.md#two-factor-sign-in)) |
141
+ | Multi-factor authentication | ✅ | As in APEX: through the identity provider (OpenID Connect, SAML). Beyond APEX (sprints 126–128): the builder itself has two-factor sign-in with passkeys (WebAuthn, also on their own without a password), authenticator apps (TOTP) and recovery codes, trusted browsers, an instance and per-workspace policy with a grace period and lockout recovery ([chapter 3](guide/03-builder.md#two-factor-sign-in)). Sprint 130: application users too, per application (off, optional, required, required for roles) with authenticator apps and recovery codes, added while signing in or on My account, reset from Users; sprint 131: passkeys, e-mail codes as a fallback, step-up per authorization scheme and REST API tokens only after a second step ([chapter 8](guide/08-security.md#two-factor-sign-in)) |
142
142
  | Change own password | ✅ | A built-in *My account* page (APEX offers the API `CHANGE_CURRENT_USER_PW`) |
143
143
  | Password expiry, change on first use, admin reset, complexity | ✅ | Lifetime in days, expire/unexpire, admin reset, length and letters+digits rules, unlock |
144
144
  | Lockout after failed sign-ins | ✅ | Per user and per IP, time-based (APEX: until an administrator unlocks) |
@@ -300,8 +300,10 @@ Small but real differences, for teams comparing the two:
300
300
  passkey alone), authenticator apps (TOTP), recovery codes, a policy for administrators, all developers or a
301
301
  workspace's developers with a grace period, and lockout recovery on the
302
302
  command line ([chapter 3](guide/03-builder.md#two-factor-sign-in)). APEX relies on the identity provider.
303
- - (sprint 130) **Two-factor sign-in for application users**: per application and per role, with authenticator apps
304
- and recovery codes, added while signing in when required ([chapter 8](guide/08-security.md#two-factor-sign-in)).
303
+ - (sprints 130–131) **Two-factor sign-in for application users**: per application and per role, with authenticator
304
+ apps, passkeys and recovery codes, added while signing in when required, a code by e-mail as a fallback, step-up
305
+ before sensitive pages (an authorization scheme's *Second step within*) and REST API tokens only after a second step
306
+ ([chapter 8](guide/08-security.md#two-factor-sign-in)).
305
307
  APEX's application authentication schemes have no second factor.
306
308
  - One RLS policy set protects both the web UI and the REST API.
307
309
  - An import can be checked before it commits (`pgkiln import --check`): SQL that names a missing
@@ -419,8 +419,8 @@ sign-on policy asks for one, or a hand-written TOTP check in a custom authentica
419
419
  | Setting | Who gives a code after the password |
420
420
  |---|---|
421
421
  | **Off** (the default) | Nobody |
422
- | **Optional** | Users who added an authenticator app on *My account* |
423
- | **Required** | Everybody. A user without an app adds one while signing in: a QR code, the app's first code, then ten recovery codes shown once |
422
+ | **Optional** | Users who added an authenticator app or a passkey on *My account* |
423
+ | **Required** | Everybody. A user without one adds an app (a QR code, then its first code) or a passkey while signing in, then sees ten recovery codes once |
424
424
  | **Required for some roles** | Users with one of the roles in *Required for roles* (for example `admin, manager`); others as with *Optional* |
425
425
 
426
426
  How it works:
@@ -447,8 +447,41 @@ How it works:
447
447
  the step. Recovery codes are stored as SHA-256 hashes.
448
448
  - The **Advisor** warns when users with the role `admin` sign in with only a password.
449
449
 
450
- Passkeys, e-mail codes, asking again before a sensitive page (step-up) and tokens for REST and MCP that need a
451
- two-factor sign-in come in a later release.
450
+ #### Passkeys
451
+
452
+ Next to authenticator apps, users add **passkeys** (WebAuthn: a fingerprint, face or screen lock on the device, or a
453
+ security key) on *My account*, or while signing in when the application requires a second step and they have none
454
+ yet (the page offers an app and a passkey). At the second step **Use a passkey** replaces the code. pgkiln checks the
455
+ answer itself (challenge, origin, relying party, signature, counter), as for [developers](03-builder.md#two-factor-sign-in).
456
+ A passkey works only at the host name it was made for (`PUBLIC_URL`'s host, or `PGKILN_RP_ID`), not at an IP address,
457
+ and needs JavaScript; codes keep working without either. Passkeys are public keys: they don't need the instance key.
458
+ The first factor a user adds, app or passkey, brings ten recovery codes.
459
+
460
+ #### A code by e-mail
461
+
462
+ **Offer a code by e-mail** (same settings) adds *E-mail me a code instead* to the second step, for users who have an
463
+ authenticator app or a passkey and an e-mail address in the user directory. The code (six digits, valid 10 minutes,
464
+ once) goes through the application's mail service; at most three per sign-in and five per account per hour. It is
465
+ **never someone's only factor**: it is not offered while a user adds their first one, so a lost mailbox or a mail
466
+ outage never locks anyone out and a stolen mailbox alone never signs anyone in.
467
+
468
+ #### Asking again before a sensitive page (step-up)
469
+
470
+ An authorization scheme's **Second step within (minutes)** makes it pass only when the session's last second step
471
+ (code, passkey, e-mail code or recovery code) is at most that old. A page behind such a scheme sends a user who passes
472
+ the scheme otherwise to *Confirm it's you* (the second-step page) and back to the page afterwards; regions, buttons and
473
+ processes behind it stay hidden or skipped until then. A user without a factor is told to add one on *My account*.
474
+ Typical: `SALARY_EDIT` (role `hr_manager`, second step within 15 minutes) on the page that changes salaries. A
475
+ *Keep me signed in*, shared or single sign-on session has no recent second step of pgkiln's own, so it is asked too.
476
+ A form posted after the time ran out is not processed: the user confirms, comes back to the page and submits again.
477
+
478
+ #### REST API tokens
479
+
480
+ A developer's **REST API → Issue a token** acts as an account without its sign-in, so it is refused for accounts the
481
+ application requires a second step of. Turn on **REST API → Users create their own tokens** and those users (and
482
+ everyone else) create a token on *My account → REST API token* instead; one who must use two-factor sign-in gives a
483
+ second step first when their last one is older than 15 minutes. The token is shown once. MCP has no tokens for
484
+ application users, so nothing to cover there.
452
485
 
453
486
  ### What sign-in protects against
454
487
 
@@ -458,8 +491,8 @@ two-factor sign-in come in a later release.
458
491
  time.
459
492
  - **Session fixation**: the session token is replaced at sign-in.
460
493
  - **Stale sessions**: 60 minutes idle or 8 hours total (configurable).
461
- - **Stolen passwords**: [two-factor sign-in](#two-factor-sign-in) asks for a code from an authenticator app, for every
462
- user or for some roles; with an identity provider ([OpenID Connect](#single-sign-on-openid-connect),
494
+ - **Stolen passwords**: [two-factor sign-in](#two-factor-sign-in) asks for a code from an authenticator app or a
495
+ passkey, for every user or for some roles, and again before sensitive pages ([step-up](#asking-again-before-a-sensitive-page-step-up)); with an identity provider ([OpenID Connect](#single-sign-on-openid-connect),
463
496
  [SAML](#single-sign-on-saml-20)) the provider asks for its own. Developers have
464
497
  [two-factor sign-in](03-builder.md#two-factor-sign-in) for the builder too.
465
498
 
@@ -480,6 +513,8 @@ Plus the built-in `MUST_NOT_BE_PUBLIC_USER` (signed in). Reference a scheme by n
480
513
  `authz` property of a page, region, item, button, process, dynamic action or navigation entry;
481
514
  prefix it with `!` to negate (`!ADMIN`). Unknown schemes **fail closed** (deny). Each scheme is
482
515
  evaluated at most once per request, and its `error_message` is shown when a page is refused.
516
+ With **Second step within (minutes)** it also needs a recent second factor
517
+ ([step-up](#asking-again-before-a-sensitive-page-step-up)).
483
518
 
484
519
  What happens when a scheme fails:
485
520
 
@@ -63,9 +63,12 @@ src/
63
63
  customauth.ts custom authentication: the app's function or PL/pgSQL body (a pg_temp function) and post-authentication code, as the app's role
64
64
  remember.ts "Keep me signed in": rotating persistent sign-in tokens
65
65
  mfa.ts second factors: TOTP, recovery codes, a FactorStore per kind of user (developers, accounts);
66
- the developers' policy, passkeys, trusted browsers (webauthn.ts: WebAuthn checks)
66
+ the developers' policy, passkeys, trusted browsers (webauthn.ts: WebAuthn checks and the
67
+ session's one-time challenge)
67
68
  account-mfa.ts (sprint 130) two-factor sign-in for application accounts: the application's rule (off, optional,
68
- required, roles), secondStep, carriesSecondStep (remembered/shared sign-ins), resets
69
+ required, roles), secondStep, carriesSecondStep (remembered/shared sign-ins), resets;
70
+ (sprint 131) account passkeys, e-mail codes (emailCodeTarget, queueEmailCode), the session's
71
+ last second step (__MFA_AT, secondStepWithin) for step-up, accountMustUseMfa (API tokens)
69
72
  workflow.ts workflows: step checks, the runner with parallel branches (NOTIFY + polling), invoke_api steps
70
73
  (the call between two transactions, with a lease), Advisor references, the diagram;
71
74
  (sprint 114) send_mail and draft steps, switch cases on task outcomes, the diagram as a standalone SVG
@@ -176,9 +179,12 @@ src/
176
179
  signin.ts the sign-in form (built-in page and the login region of an application's own login page), safeNext, passwordResetAvailable
177
180
  password-reset.ts Forgot password: request (same answer either way), the link (token into the session), the new password
178
181
  two-step.ts (sprint 130) the second step of signing in (/login/2fa: a code, or the first app and its recovery
179
- codes), passwordSignIn, and My account → Two-factor sign-in
182
+ codes), passwordSignIn, and My account → Two-factor sign-in; (sprint 131) passkeys at the
183
+ second step and as the first factor, e-mail codes, step-up (?next=, a signed-in user), My
184
+ account → REST API token (tokenSection)
180
185
  context.ts PageContext, dbg()/timed() debug helpers, bind values, substitutions, public error messages, writeOut (streamed responses with back pressure)
181
- authz.ts authorization schemes, conditions, visibility (menu requests count as buttons)
186
+ authz.ts authorization schemes, conditions, visibility (menu requests count as buttons); (sprint 131)
187
+ step-up (mfa_minutes; StepUp sends the page's user to /login/2fa?next=)
182
188
  engine.ts form fetch, validations, processes (conditions, execution chains, queueing background chains; web requests queued by an sql process are made right after it), application processes
183
189
  processes.ts download (file or zip from a query, safe headers), workflow processes, configuration checks of chains
184
190
  logic.ts computations (the page's and (102) the application's), branches (page, URL, function returning a URL, another application) and their conditions
@@ -362,6 +362,10 @@ curl https://api.example.com/employees -H "Authorization: Bearer eyJ…"
362
362
  Under **REST API → Issue a token**, pick an account and a lifetime (1 hour to 30 days). The token
363
363
  is shown once and not stored. Issuing is logged in the activity log (`api_token`).
364
364
 
365
+ An account the application requires two-factor sign-in of gets no token here: with **Users create their
366
+ own tokens** on, it creates one on *My account → REST API token* after a recent second step
367
+ ([chapter 8](08-security.md#rest-api-tokens)).
368
+
365
369
  Such a token contains `role`, `app` and `app_user`, but **no roles**. `meta.has_role()` reads the
366
370
  account's roles in the application at every request, and `meta.api_check()` rejects the token as
367
371
  soon as the account is deactivated or loses access. Changing `API_JWT_SECRET` (in pgkiln and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
package/public/app.js CHANGED
@@ -4299,3 +4299,66 @@ document.addEventListener('change', (e) => {
4299
4299
  }
4300
4300
  });
4301
4301
  })();
4302
+
4303
+ // (sprint 127, moved here from builder.js in sprint 131) Passkeys, for developers (the builder loads app.js too) and
4304
+ // application users: a [data-passkey] button asks the server (its URL) for WebAuthn options, calls
4305
+ // navigator.credentials.create() (Security, My account, adding one while signing in) or .get() (the second step, a
4306
+ // passkey alone on the builder's sign-in page) and posts the answer in its own form, to data-passkey-submit when set.
4307
+ // Without WebAuthn or JavaScript the buttons stay hidden; codes keep working.
4308
+ (() => {
4309
+ 'use strict';
4310
+ if (!window.PublicKeyCredential || !navigator.credentials) return;
4311
+ const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
4312
+ const bytes = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4)), (c) => c.charCodeAt(0));
4313
+ for (const btn of document.querySelectorAll('[data-passkey]')) {
4314
+ const form = btn.closest('form');
4315
+ if (!form) continue;
4316
+ btn.hidden = false;
4317
+ btn.addEventListener('click', async (e) => {
4318
+ e.preventDefault();
4319
+ const status = form.querySelector('[data-passkey-status]');
4320
+ const set = (name, value) => {
4321
+ const field = form.querySelector(`[name="${name}"]`);
4322
+ if (field) field.value = value;
4323
+ };
4324
+ btn.disabled = true;
4325
+ if (status) status.textContent = '';
4326
+ try {
4327
+ const res = await fetch(btn.dataset.passkey, {
4328
+ method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json' },
4329
+ body: new URLSearchParams({ __csrf: form.querySelector('[name="__csrf"]').value }),
4330
+ });
4331
+ if (!res.ok) throw new Error(String(res.status));
4332
+ const o = await res.json();
4333
+ if (o.mode === 'create') {
4334
+ const c = await navigator.credentials.create({ publicKey: {
4335
+ challenge: bytes(o.challenge), rp: o.rp,
4336
+ user: { id: bytes(o.user.id), name: o.user.name, displayName: o.user.displayName },
4337
+ pubKeyCredParams: o.algs.map((alg) => ({ type: 'public-key', alg })),
4338
+ excludeCredentials: o.exclude.map((id) => ({ type: 'public-key', id: bytes(id) })),
4339
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
4340
+ attestation: 'none', timeout: 120000,
4341
+ } });
4342
+ set('clientDataJSON', b64(c.response.clientDataJSON));
4343
+ set('attestationObject', b64(c.response.attestationObject));
4344
+ set('transports', (c.response.getTransports ? c.response.getTransports() : []).join(','));
4345
+ } else {
4346
+ const c = await navigator.credentials.get({ publicKey: {
4347
+ challenge: bytes(o.challenge), rpId: o.rpId, userVerification: o.uv || 'preferred', timeout: 120000,
4348
+ allowCredentials: o.allow.map((a) => ({ type: 'public-key', id: bytes(a.id), transports: a.transports })),
4349
+ } });
4350
+ set('credentialId', b64(c.rawId));
4351
+ set('clientDataJSON', b64(c.response.clientDataJSON));
4352
+ set('authenticatorData', b64(c.response.authenticatorData));
4353
+ set('signature', b64(c.response.signature));
4354
+ if (c.response.userHandle) set('userHandle', b64(c.response.userHandle));
4355
+ }
4356
+ if (btn.dataset.passkeySubmit) form.action = btn.dataset.passkeySubmit;
4357
+ form.submit();
4358
+ } catch (err) {
4359
+ btn.disabled = false;
4360
+ if (status) status.textContent = btn.dataset.passkeyError || String(err);
4361
+ }
4362
+ });
4363
+ }
4364
+ })();
package/public/builder.js CHANGED
@@ -1334,65 +1334,4 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
1334
1334
  }
1335
1335
  })();
1336
1336
 
1337
- // (sprint 127) Passkeys: a [data-passkey] button asks the server (its URL) for WebAuthn options, calls
1338
- // navigator.credentials.create() (Security page) or .get() (the second sign-in step) and posts the answer in
1339
- // its own form, to data-passkey-submit when set, so the server answers with a redirect as for any form.
1340
- // (sprint 128) On the sign-in page the list of allowed passkeys is empty: the browser offers the ones it holds.
1341
- // Without WebAuthn or JavaScript the buttons stay hidden; codes keep working.
1342
- (() => {
1343
- 'use strict';
1344
- if (!window.PublicKeyCredential || !navigator.credentials) return;
1345
- const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1346
- const bytes = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4)), (c) => c.charCodeAt(0));
1347
- for (const btn of document.querySelectorAll('[data-passkey]')) {
1348
- const form = btn.closest('form');
1349
- if (!form) continue;
1350
- btn.hidden = false;
1351
- btn.addEventListener('click', async (e) => {
1352
- e.preventDefault();
1353
- const status = form.querySelector('[data-passkey-status]');
1354
- const set = (name, value) => {
1355
- const field = form.querySelector(`[name="${name}"]`);
1356
- if (field) field.value = value;
1357
- };
1358
- btn.disabled = true;
1359
- if (status) status.textContent = '';
1360
- try {
1361
- const res = await fetch(btn.dataset.passkey, {
1362
- method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json' },
1363
- body: new URLSearchParams({ __csrf: form.querySelector('[name="__csrf"]').value }),
1364
- });
1365
- if (!res.ok) throw new Error(String(res.status));
1366
- const o = await res.json();
1367
- if (o.mode === 'create') {
1368
- const c = await navigator.credentials.create({ publicKey: {
1369
- challenge: bytes(o.challenge), rp: o.rp,
1370
- user: { id: bytes(o.user.id), name: o.user.name, displayName: o.user.displayName },
1371
- pubKeyCredParams: o.algs.map((alg) => ({ type: 'public-key', alg })),
1372
- excludeCredentials: o.exclude.map((id) => ({ type: 'public-key', id: bytes(id) })),
1373
- authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
1374
- attestation: 'none', timeout: 120000,
1375
- } });
1376
- set('clientDataJSON', b64(c.response.clientDataJSON));
1377
- set('attestationObject', b64(c.response.attestationObject));
1378
- set('transports', (c.response.getTransports ? c.response.getTransports() : []).join(','));
1379
- } else {
1380
- const c = await navigator.credentials.get({ publicKey: {
1381
- challenge: bytes(o.challenge), rpId: o.rpId, userVerification: o.uv || 'preferred', timeout: 120000,
1382
- allowCredentials: o.allow.map((a) => ({ type: 'public-key', id: bytes(a.id), transports: a.transports })),
1383
- } });
1384
- set('credentialId', b64(c.rawId));
1385
- set('clientDataJSON', b64(c.response.clientDataJSON));
1386
- set('authenticatorData', b64(c.response.authenticatorData));
1387
- set('signature', b64(c.response.signature));
1388
- if (c.response.userHandle) set('userHandle', b64(c.response.userHandle));
1389
- }
1390
- if (btn.dataset.passkeySubmit) form.action = btn.dataset.passkeySubmit;
1391
- form.submit();
1392
- } catch (err) {
1393
- btn.disabled = false;
1394
- if (status) status.textContent = btn.dataset.passkeyError || String(err);
1395
- }
1396
- });
1397
- }
1398
- })();
1337
+ // (sprint 127) Passkeys: the [data-passkey] buttons are handled by app.js (sprint 131), which every builder page loads too.
@@ -1,5 +1,9 @@
1
+ import { createHash, randomInt, timingSafeEqual } from 'node:crypto';
1
2
  import { owner } from './db.ts';
2
- import { ACCOUNTS, storeAddTotp, storeCheck, storeCodesLeft, storeConfirmCodes, storeHasFactor, storeNewCodes } from './mfa.ts';
3
+ import {
4
+ ACCOUNTS, storeAddPasskey, storeAddTotp, storeCheck, storeCodesLeft, storeConfirmCodes, storeHasFactor, storeNewCodes, storePasskeys,
5
+ storeUsePasskey,
6
+ } from './mfa.ts';
3
7
 
4
8
  // (sprint 130, SEC-3) Two-step sign-in for application users, beyond APEX (whose application authentication has no
5
9
  // second factor). An account's authenticator apps and recovery codes (meta.account_factor, account_recovery_code,
@@ -7,6 +11,9 @@ import { ACCOUNTS, storeAddTotp, storeCheck, storeCodesLeft, storeConfirmCodes,
7
11
  // off, optional (users who added an app give a code), required, or required for some roles. The second step follows
8
12
  // the user directory's passwords and LDAP directories; an identity provider decides on its own second factor (SEC-4).
9
13
  // A remembered or shared sign-in carries its method: only one that ends in "+2fa" stands in for the second step.
14
+ // (sprint 131, SEC-3 part 2) Passkeys next to authenticator apps; a code by e-mail for users who have one of those
15
+ // (never someone's only factor, SEC-6); step-up: an authorization scheme with mfa_minutes passes only when the session's
16
+ // last second step (session state __MFA_AT) is that recent; and REST API tokens a user creates on My account.
10
17
 
11
18
  export type AppMfa = 'off' | 'optional' | 'required' | 'roles';
12
19
  export const APP_MFA: AppMfa[] = ['off', 'optional', 'required', 'roles'];
@@ -55,10 +62,11 @@ export async function carriesSecondStep(a: MfaApp, username: string, roles: stri
55
62
  return (await secondStep(a, username, roles)) === null;
56
63
  }
57
64
 
58
- export interface AccountFactor { id: number; name: string; created_at: string; last_used_at: string | null }
65
+ export interface AccountFactor { id: number; kind: 'totp' | 'passkey'; name: string; created_at: string; last_used_at: string | null; rp_id: string | null }
59
66
 
60
67
  export async function accountFactors(id: number): Promise<AccountFactor[]> {
61
- return (await owner.query<AccountFactor>('select id, name, created_at, last_used_at from meta.account_factor where account_id = $1 order by id', [id])).rows;
68
+ return (await owner.query<AccountFactor>(
69
+ 'select id, kind, name, created_at, last_used_at, rp_id from meta.account_factor where account_id = $1 order by kind desc, id', [id])).rows;
62
70
  }
63
71
 
64
72
  export const accountHasFactor = (id: number) => storeHasFactor(ACCOUNTS, id);
@@ -67,6 +75,89 @@ export const addAccountTotp = (id: number, name: string, secret: string, step: n
67
75
  export const newAccountCodes = (id: number) => storeNewCodes(ACCOUNTS, id);
68
76
  export const confirmAccountCodes = (id: number) => storeConfirmCodes(ACCOUNTS, id);
69
77
  export const checkAccountCode = (id: number, code: string) => storeCheck(ACCOUNTS, id, code);
78
+ export const addAccountPasskey = (id: number, name: string, p: Parameters<typeof storeAddPasskey>[3]) => storeAddPasskey(ACCOUNTS, id, name, p);
79
+ export const accountPasskeys = (id: number, rpId: string) => storePasskeys(ACCOUNTS, id, rpId);
80
+ export const useAccountPasskey = (factorId: number, oldCount: string, newCount: number) => storeUsePasskey(ACCOUNTS, factorId, oldCount, newCount);
81
+ /** The user handle an account's passkeys carry: stable, and not the user name. */
82
+ export const accountUserHandle = (id: number) => createHash('sha256').update(`pgkiln-account:${id}`).digest().subarray(0, 16).toString('base64url');
83
+
84
+ // ---------------------------------------------------------------- step-up (sprint 131)
85
+
86
+ /** Session state: when (ms) the session last had a second step (signing in with one, or asked again). */
87
+ export const MFA_AT = '__MFA_AT';
88
+ export const markSecondStep = (state: Record<string, unknown>) => { state[MFA_AT] = Date.now(); };
89
+ /** Whether the session had a second step at most `minutes` ago. */
90
+ export function secondStepWithin(state: Record<string, unknown>, minutes: number): boolean {
91
+ const at = Number(state[MFA_AT]);
92
+ return Number.isFinite(at) && at > 0 && Date.now() - at <= minutes * 60_000;
93
+ }
94
+
95
+ // ---------------------------------------------------------------- e-mail codes (sprint 131)
96
+
97
+ export const EMAIL_CODE_MINUTES = 10;
98
+ /** Codes one account may get by e-mail per hour (every application together). */
99
+ export const EMAIL_CODES_PER_HOUR = 5;
100
+ export const newEmailCode = () => String(randomInt(0, 1_000_000)).padStart(6, '0');
101
+ export const emailCodeHash = (code: string) => createHash('sha256').update(`pgkiln-mail-code:${code}`).digest('hex');
102
+ export function emailCodeMatches(hash: string | undefined, code: string): boolean {
103
+ const digits = code.replace(/\s/g, '');
104
+ if (!hash || !/^\d{6}$/.test(digits)) return false;
105
+ return timingSafeEqual(Buffer.from(emailCodeHash(digits)), Buffer.from(hash));
106
+ }
107
+
108
+ /**
109
+ * Where an e-mail code would go for this account in this application: the account's address and the application's
110
+ * mail service, or null (not offered). Only for an application that allows it and an account with another factor.
111
+ */
112
+ export async function emailCodeTarget(a: { id: number; mfa_email?: boolean | null }, accountIdValue: number): Promise<{ to: string; from: string } | null> {
113
+ if (!a.mfa_email || !(await storeHasFactor(ACCOUNTS, accountIdValue))) return null;
114
+ const r = await owner.one<{ email: string | null; from_address: string | null; ok: boolean }>(
115
+ `select ac.email, ams.from_address, coalesce(s.enabled and meta.mail_sender_allowed(s.senders, ams.from_address), false) as ok
116
+ from meta.account ac
117
+ left join meta.app_mail_service ams on ams.app_id = $2
118
+ left join meta.mail_service s on s.id = ams.service_id
119
+ where ac.id = $1 and ac.active`, [accountIdValue, a.id]);
120
+ const to = r?.email?.trim();
121
+ return r?.ok && to && r.from_address?.trim() ? { to, from: r.from_address.trim() } : null;
122
+ }
123
+
124
+ /**
125
+ * Queue a sign-in code mail (the application's mail service, its daily limit). Returns false when the account had
126
+ * too many this hour or the limit is reached; the caller says nothing more specific (no hint about the address).
127
+ */
128
+ export async function queueEmailCode(a: { id: number }, username: string, target: { to: string; from: string }, subject: string, text: string): Promise<boolean> {
129
+ return owner.tx(async (c) => {
130
+ const recent = (await c.query<{ n: number }>(
131
+ `select count(*)::int as n from meta.mail_queue where template = 'SIGNIN_CODE' and lower(username) = lower($1) and created_at > now() - interval '1 hour'`,
132
+ [username])).rows[0].n;
133
+ if (recent >= EMAIL_CODES_PER_HOUR) return false;
134
+ const over = (await c.query<{ over: boolean }>(
135
+ `select ams.max_per_day is not null and (select count(*) from meta.mail_queue q where q.app_id = $1
136
+ and q.created_at >= date_trunc('day', now() at time zone 'UTC') at time zone 'UTC' and q.status <> 'cancelled') >= ams.max_per_day as over
137
+ from meta.app_mail_service ams where ams.app_id = $1`, [a.id])).rows[0]?.over;
138
+ if (over) return false;
139
+ const id = (await c.query<{ id: string }>(
140
+ `insert into meta.mail_queue (app_id, username, from_address, to_addresses, subject, body_text, template)
141
+ values ($1, $2, $3, array[$4], left($5, 998), $6, 'SIGNIN_CODE') returning id`,
142
+ [a.id, username, target.from, target.to, subject.replace(/[\r\n]+/g, ' '), text])).rows[0].id;
143
+ await c.query(`select pg_notify('pgkiln_mail', $1)`, [String(id)]);
144
+ return true;
145
+ });
146
+ }
147
+
148
+ // ---------------------------------------------------------------- REST API tokens (sprint 131)
149
+
150
+ /** Minutes a second step may be old for a user to create their own API token (when the app requires one of them). */
151
+ export const TOKEN_STEP_MINUTES = 15;
152
+
153
+ /** Whether the application requires a second step of this account (by its roles in the application). */
154
+ export async function accountMustUseMfa(a: MfaApp & { id: number }, username: string): Promise<boolean> {
155
+ if (!mfaOn(a)) return false;
156
+ const roles = (await owner.query<{ roles: string[] }>(
157
+ `select aa.roles from meta.app_access aa join meta.account ac on ac.id = aa.account_id where aa.app_id = $1 and lower(ac.username) = lower($2)`,
158
+ [a.id, username])).rows[0]?.roles ?? [];
159
+ return mfaRequired(a, roles);
160
+ }
70
161
 
71
162
  /** Remove one of the account's authenticator apps (and the recovery codes with the last one). */
72
163
  export async function removeAccountFactor(id: number, factorId: number): Promise<boolean> {
package/src/api.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SignJWT } from 'jose';
2
+ import { accountMustUseMfa } from './account-mfa.ts';
2
3
  import { owner } from './db.ts';
3
4
 
4
5
  // Tokens for the REST API that PostgREST serves next to pgkiln. They're
@@ -47,9 +48,11 @@ export async function apiStatus(): Promise<{ ok: boolean; detail: string }> {
47
48
  * active and have access to the app. Roles are not put in the token:
48
49
  * meta.has_role() reads them from meta.app_access at each request, and
49
50
  * meta.api_check() rejects the token once the account loses access.
51
+ * (sprint 131) `self`: the user asked for it on My account (two-step.ts checked the second step); `internal`: the
52
+ * token never leaves the server (the builder's Try it).
50
53
  */
51
- export async function issueApiToken(appId: number, username: string, hours: number) {
52
- const app = await owner.one('select alias, api_role, access_control from meta.app where id = $1', [appId]);
54
+ export async function issueApiToken(appId: number, username: string, hours: number, opts: { self?: boolean; internal?: boolean } = {}) {
55
+ const app = await owner.one('select id, alias, api_role, access_control, authentication, mfa, mfa_roles from meta.app where id = $1', [appId]);
53
56
  if (!app) throw new Error('No such application.');
54
57
  // without an API role the token is for pgkiln's own REST modules only (they run as the app's role)
55
58
  const problem = app.api_role ? await apiRoleProblem(app.api_role) : null;
@@ -63,6 +66,10 @@ export async function issueApiToken(appId: number, username: string, hours: numb
63
66
  if (!acc) throw new Error(`There is no account "${username}".`);
64
67
  if (!acc.active) throw new Error('This account is inactive.');
65
68
  if (!acc.has_access && app.access_control !== 'any_user') throw new Error('This account has no access to the application.');
69
+ // (sprint 131, SEC-3) a user who must sign in in two steps gets a token only from their own sign-in, after a second
70
+ // step (My account, src/runtime/two-step.ts), never one a developer issues for them
71
+ if (!opts.self && !opts.internal && (await accountMustUseMfa(app, acc.username)))
72
+ throw new Error(`The application requires two-factor sign-in of "${acc.username}": they create their own on My account, after a second step (REST API → Users create their own tokens).`);
66
73
  const ttl = Math.max(1, Math.min(MAX_TOKEN_HOURS, Math.round(hours)));
67
74
  const token = await new SignJWT({ ...(app.api_role ? { role: app.api_role } : {}), app_user: acc.username, app: app.alias })
68
75
  .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
@@ -123,6 +123,8 @@ async function apiPage(s: Session, a: any, issued?: { token: string; username: s
123
123
  <form method="post" action="${BASE}/apps/${a.id}/api">${csrf(s)}
124
124
  <div class="form-grid">
125
125
  ${input('api_role', 'API database role', role, { placeholder: 'e.g. myapp_api', help: 'For PostgREST: grant it only the api schema’s views and functions, and grant it to pgkiln_authenticator. Empty: tokens work for the REST modules pgkiln serves (Shared Components → REST modules) only.' })}
126
+ <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="api_self_tokens" value="true"${a.api_self_tokens ? raw(' checked') : ''}> Users create their own tokens</label>
127
+ <small class="help">On <em>My account</em>, signed-in users create a token that acts as them. A user the application requires two-factor sign-in of gives a second step first (if their last one is older than 15 minutes); for such users this is the only way to get a token.</small></div>
126
128
  </div>
127
129
  <div class="buttons"><button class="btn btn-hot">Save</button></div>
128
130
  </form>
@@ -171,7 +173,7 @@ export async function apiRoutes(app: FastifyInstance) {
171
173
  try {
172
174
  const problem = role ? await apiRoleProblem(role) : null;
173
175
  if (problem) throw new Error(problem);
174
- await owner.query('update meta.app set api_role = $2, updated_at = now() where id = $1', [req.params.id, role]);
176
+ await owner.query('update meta.app set api_role = $2, api_self_tokens = $3, updated_at = now() where id = $1', [req.params.id, role, req.body?.api_self_tokens === 'true']);
175
177
  flash(s, role ? `API role set to ${role}.` : 'API role removed; new tokens work for the REST modules pgkiln serves only.');
176
178
  } catch (e) {
177
179
  flash(s, (e as Error).message, 'error');
@@ -420,6 +420,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
420
420
  { name: 'type', label: 'Type', kind: 'select', options: ['role', 'sql'] },
421
421
  { name: 'value', label: 'Role name / SQL expression', kind: 'code', wide: true, help: "role: e.g. admin · sql: e.g. meta.has_role('manager') or :APP_USER = 'boss'" },
422
422
  { name: 'error_message', label: 'Error message', kind: 'text', wide: true },
423
+ { name: 'mfa_minutes', label: 'Second step within (minutes)', kind: 'int', help: 'Step-up: the scheme passes only when the user gave a second factor (code or passkey) at most this many minutes ago; a page that needs it asks again, then opens. Users without one are refused. Empty: no step-up.' },
423
424
  ],
424
425
  },
425
426
  lov: {
@@ -1,6 +1,6 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
- import { APP_MFA, type AppMfa } from '../account-mfa.ts';
3
+ import { APP_MFA, EMAIL_CODE_MINUTES, EMAIL_CODES_PER_HOUR, type AppMfa } from '../account-mfa.ts';
4
4
  import { html, raw } from '../html.ts';
5
5
  import { secretKeyConfigured } from '../secrets.ts';
6
6
  import { back, BASE, developer, flash, type Req } from './ui.ts';
@@ -9,7 +9,7 @@ import { back, BASE, developer, flash, type Req } from './ui.ts';
9
9
  // own login page (APEX: the login page, usually 9999) and "Forgot password?".
10
10
  // "Create login page" adds a minimal public page with a login region and
11
11
  // makes it the login page. (sprint 130, SEC-3) Two-factor sign-in for the
12
- // application's users: off, optional, required, or required for some roles.
12
+ // application's users: off, optional, required, or required for some roles. (sprint 131) A code by e-mail.
13
13
 
14
14
  interface AppRow {
15
15
  id: number;
@@ -20,6 +20,7 @@ interface AppRow {
20
20
  password_reset: boolean;
21
21
  mfa: AppMfa;
22
22
  mfa_roles: string[];
23
+ mfa_email: boolean;
23
24
  }
24
25
 
25
26
  /** The fields in the settings form. */
@@ -56,7 +57,11 @@ export async function loginSettingsFields(a: AppRow) {
56
57
  <div class="field"><label class="label" for="f_mfa_roles">Required for roles</label>
57
58
  <input id="f_mfa_roles" name="mfa_roles" value="${(a.mfa_roles ?? []).join(', ')}" placeholder="admin, manager" aria-describedby="f_mfa_roles_help">
58
59
  <small class="help" id="f_mfa_roles_help">With <em>Required for some roles</em>: users with one of these roles must use it, others may.</small></div>
59
- </div>`;
60
+ <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="mfa_email" value="true"${a.mfa_email ? raw(' checked') : ''}> Offer a code by e-mail</label>
61
+ <small class="help">At the second step, users who have an authenticator app or a passkey may ask for a one-time code by e-mail instead (valid ${EMAIL_CODE_MINUTES} minutes, at most ${EMAIL_CODES_PER_HOUR} an hour). It is never someone's only factor: a user who must add one still adds an app or a passkey.
62
+ ${mailOk ? '' : html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address.`}</small></div>
63
+ </div>
64
+ <p class="muted small">Users also add <strong>passkeys</strong> (fingerprint, face, PIN or a security key) on <em>My account</em> or while signing in; they need the application at a host name, not an IP address. To ask for the second step again before a page opens (step-up), give its authorization scheme <em>Second step within (minutes)</em> in Shared Components.</p>`;
60
65
  }
61
66
 
62
67
  const MFA_LABELS: Record<AppMfa, string> = { off: 'Off', optional: 'Optional (users may add an app)', required: 'Required', roles: 'Required for some roles' };
@@ -71,8 +76,8 @@ export async function saveLoginSettings(appId: string, b: Record<string, string
71
76
  if (mfa === 'roles' && !roles.length) throw new Error('Two-factor sign-in: name the roles that must use it.');
72
77
  // without the instance key, users could not add an app: requiring one would lock them out
73
78
  if ((mfa === 'required' || mfa === 'roles') && !secretKeyConfigured()) throw new Error('Two-factor sign-in: the server needs PGKILN_SECRET_KEY before it can be required.');
74
- await owner.query('update meta.app set login_page = $2, password_reset = $3, mfa = $4, mfa_roles = $5 where id = $1',
75
- [appId, page, b.password_reset === 'true', mfa, roles]);
79
+ await owner.query('update meta.app set login_page = $2, password_reset = $3, mfa = $4, mfa_roles = $5, mfa_email = $6 where id = $1',
80
+ [appId, page, b.password_reset === 'true', mfa, roles, b.mfa_email === 'true']);
76
81
  }
77
82
 
78
83
  export async function loginPageRoutes(app: FastifyInstance) {
@@ -599,7 +599,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
599
599
  const headers: Record<string, string> = {};
600
600
  if (as) {
601
601
  if (!(await tryAccounts(m.app_id)).includes(as)) throw new Error('Choose an account with access to the application.');
602
- headers.authorization = `Bearer ${(await issueApiToken(m.app_id, as, 1)).token}`;
602
+ headers.authorization = `Bearer ${(await issueApiToken(m.app_id, as, 1, { internal: true })).token}`;
603
603
  }
604
604
  const raw = h.method === 'GET' || h.method === 'DELETE' ? '' : String(b.body ?? '').trim();
605
605
  if (raw) {
@@ -11,7 +11,7 @@ import {
11
11
  import { qrSvg } from '../qrcode.ts';
12
12
  import { secretKeyConfigured } from '../secrets.ts';
13
13
  import { clientIp, logActivity, saveState, type Session } from '../session.ts';
14
- import { ALGORITHMS, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
14
+ import { ALGORITHMS, giveChallenge, relyingParty, takeChallenge, verifyRegistration, WebAuthnError } from '../webauthn.ts';
15
15
  import { tr } from './i18n.ts';
16
16
  import { isAdmin } from './locks.ts';
17
17
  import { identitiesRegion } from './builder-sso.ts';
@@ -28,24 +28,8 @@ import { back, BASE, csrf, developer, flash, input, region, send, shell, type Re
28
28
  // (public/builder.js, [data-passkey]); without JavaScript the button stays hidden. Trusted browsers can be
29
29
  // forgotten here; the instance decides how long a browser may be trusted (0 = never).
30
30
 
31
- const CHALLENGE_SECONDS = 300;
32
-
33
- /** A one-time WebAuthn challenge kept in the session (for this purpose only), or null when there is none or it expired. */
34
- export function takeChallenge(s: Session, purpose: 'create' | 'get'): string | null {
35
- try {
36
- const p = JSON.parse(String(s.state.__PK ?? ''));
37
- delete s.state.__PK;
38
- return p && p.purpose === purpose && Date.now() - Number(p.at) < CHALLENGE_SECONDS * 1000 && typeof p.c === 'string' ? p.c : null;
39
- } catch {
40
- delete s.state.__PK;
41
- return null;
42
- }
43
- }
44
- export function giveChallenge(s: Session, purpose: 'create' | 'get'): string {
45
- const c = newChallenge();
46
- s.state.__PK = JSON.stringify({ c, purpose, at: Date.now() });
47
- return c;
48
- }
31
+ // (sprint 131) the challenge helpers moved to src/webauthn.ts: application users have passkeys too
32
+ export { giveChallenge, takeChallenge };
49
33
 
50
34
  const SECRET_MINUTES = 10;
51
35