pgkiln 0.63.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.
- package/SECURITY.md +2 -0
- package/db/migrations/132_app_user_mfa.sql +58 -0
- package/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
- package/docs/apex-feature-parity.md +6 -1
- package/docs/guide/08-security.md +81 -5
- package/docs/guide/09-reference.md +5 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +13 -1
- package/docs/guide/13-rest-api.md +4 -0
- package/package.json +1 -1
- package/public/app.css +10 -0
- package/public/app.js +63 -0
- package/public/builder.js +1 -62
- package/src/account-mfa.ts +184 -0
- package/src/api.ts +9 -2
- package/src/builder/advisor.ts +8 -0
- package/src/builder/api.ts +3 -1
- package/src/builder/components.ts +1 -0
- package/src/builder/i18n/de.ts +6 -0
- package/src/builder/i18n/en.ts +6 -0
- package/src/builder/i18n/es.ts +6 -0
- package/src/builder/i18n/fr.ts +6 -0
- package/src/builder/i18n/nl.ts +6 -0
- package/src/builder/i18n/pt.ts +6 -0
- package/src/builder/login-page.ts +32 -3
- package/src/builder/rest-modules.ts +1 -1
- package/src/builder/security.ts +3 -19
- package/src/builder/shared.ts +5 -3
- package/src/builder/users.ts +34 -3
- package/src/i18n/ar.ts +65 -0
- package/src/i18n/cs.ts +65 -0
- package/src/i18n/da.ts +65 -0
- package/src/i18n/de.ts +65 -0
- package/src/i18n/el.ts +65 -0
- package/src/i18n/es.ts +65 -0
- package/src/i18n/fi.ts +65 -0
- package/src/i18n/fr.ts +65 -0
- package/src/i18n/he.ts +65 -0
- package/src/i18n/it.ts +65 -0
- package/src/i18n/ja.ts +65 -0
- package/src/i18n/ko.ts +65 -0
- package/src/i18n/nb.ts +65 -0
- package/src/i18n/pl.ts +65 -0
- package/src/i18n/pt.ts +65 -0
- package/src/i18n/ru.ts +65 -0
- package/src/i18n/sv.ts +65 -0
- package/src/i18n/tr.ts +65 -0
- package/src/i18n/uk.ts +65 -0
- package/src/i18n/zh.ts +65 -0
- package/src/i18n.ts +130 -0
- package/src/metadata.ts +11 -2
- package/src/mfa.ts +94 -65
- package/src/remember.ts +1 -1
- package/src/runtime/account.ts +7 -1
- package/src/runtime/authz.ts +23 -4
- package/src/runtime/routes.ts +21 -7
- package/src/runtime/two-step.ts +702 -0
- package/src/sharedlogin.ts +1 -1
- package/src/webauthn.ts +20 -0
package/SECURITY.md
CHANGED
|
@@ -116,6 +116,7 @@ first version (MVP). Every finding below has a regression test in
|
|
|
116
116
|
| Passkeys and trusted browsers (0.61.0) | Passkeys (WebAuthn) are checked in `src/webauthn.ts` without a library: a 32-byte one-time challenge per purpose (registration or sign-in) kept in the session for five minutes and used up on the first answer, the `clientDataJSON` type, challenge and origin (`PUBLIC_URL`'s, else the browser's own), the SHA-256 of the relying party id in the authenticator data, user presence, an ES256, EdDSA or RS256 signature over the authenticator data and the client data hash, and a signature counter that must go up (updated atomically, so a replayed or cloned key is refused). The CBOR reader takes only definite lengths, at most 16 levels and 1,000 entries. Credentials are public keys (no secret to leak, independent of the instance key); attestation is *none*. `PGKILN_RP_ID` is only honoured when it is the host or a parent domain of it. The sign-in options (the developer's credential ids) are only given after the password step, with the CSRF token. Trusted browsers (off by default) are a random 256-bit cookie token (`HttpOnly`, `SameSite=Lax`, `Secure` with `COOKIE_SECURE`, path `/builder`) of which only the SHA-256 is stored; the cookie is only looked at after a correct password, for that developer, and ends with its days, a new password, removing a factor, a reset, *Forget trusted browsers* or turning trust off (which removes every trusted browser at once). |
|
|
117
117
|
| Passkey sign-in without a password, workspace rule (0.62.0) | *Sign in with a passkey* asks the browser for any passkey of this site (empty allow list) with `userVerification: required`, and the server refuses an answer whose authenticator data lacks the user-verified flag, so the passkey alone stands for possession plus a fingerprint, face or PIN. The credential id is looked up for this relying party only; a user handle, when the browser sends one, must be the developer's (a SHA-256-derived handle, not the user name). The same one-time challenge, origin, RP ID hash, signature and counter checks as at the second step apply; failures are logged as `login_failed` with the developer's name when the key is known and count for the per-user and per-IP lockout. Administrators turn it off in Instance settings. A workspace's rule (administrators and its own administrators only, CSRF-checked) can only add a requirement; `pgkiln admin set-2fa-policy off` clears every workspace's rule. While a second factor is required of a developer, a sign-in with the default password leads only to the password change. |
|
|
118
118
|
| Single sign-on for developers (0.63.0) | A provider or directory signs developers in only with *builder* turned on. The flow is the applications' (state, PKCE, nonce, browser-binding cookie, ID token or SAML assertion checks) with a pending row without an application; developers are found by the provider's stable subject in `meta.developer_identity`, never by a name alone. An existing developer is linked by name only when the administrator allows it, once, never a break-glass administrator, and an e-mail user name only when verified; otherwise the developer links the identity on Security, which needs their session and CSRF token to start and the same browser to finish, and one identity can't be linked to two developers. Automatic accounts need the developers group when one is set and start as developers; the administrators group decides the role at every sign-in but never demotes a break-glass administrator. A workspace's single sign-on rule (administrators and its own administrators, CSRF-checked) refuses the pgkiln password and a passkey alone, after the password check so it reveals nothing; it can only be turned on while a break-glass administrator exists and by someone who could still sign in; the last break-glass administrator and a developer's last identity can't be removed while it applies. Failures are logged as `login_failed` with the reason. |
|
|
119
|
+
| Two-factor sign-in for application users (0.64.0) | Per application (`meta.app.mfa`: off, optional, required, required for roles). The account's TOTP secrets are encrypted with `PGKILN_SECRET_KEY` and stored only after a code confirms them; recovery codes are SHA-256 and used up atomically; each 30-second step once, one step either side. A password or LDAP sign-in that needs the step creates no session: the pending sign-in (5 minutes, 10 while adding an app) lives in the signed-out session, bound to it and to the page's CSRF token, and a forged token ends it. Wrong codes count as failed sign-ins (the per-user and per-IP lockout). A *Keep me signed in* or session-sharing token stands in for the step only when its method ends in `+2fa`; an identity provider's sign-in is left to the provider. Without the instance key the step can't be required and enrolment refuses rather than skips. Removing an app needs a current or recovery code and is refused for the last app while the application requires it; a developer's reset (Users, with a reason) removes the apps and codes and ends the account's sessions, remembered and shared sign-ins. Every enrolment, removal, reset and used recovery code is in the activity log. The factor tables are closed to the runtime roles. |
|
|
119
120
|
|
|
120
121
|
## Findings of the MVP review and their fixes
|
|
121
122
|
|
|
@@ -240,3 +241,4 @@ A second full review, mainly of what end users and API callers can reach. Regres
|
|
|
240
241
|
10. With database-account authentication: list exact roles or use a dedicated membership role, and limit what those roles may connect to in `pg_hba.conf` (they can reach the database with their password outside pgkiln too).
|
|
241
242
|
11. Turn on two-factor sign-in for the builder (Instance settings → Two-factor sign-in, at least for administrators) once two administrators have a passkey or an authenticator app; set `PUBLIC_URL` to the address developers use (passkeys belong to its host name), require it per workspace where the instance policy is too broad, keep *Trust a browser* short or at 0 on shared machines, keep `PGKILN_SECRET_KEY` backed up, and keep the recovery commands (`pgkiln admin reset-2fa`) for people with server access only.
|
|
242
243
|
12. With single sign-on for developers: leave *Link an existing developer with the same user name* off unless you trust the provider's user names, set a developers group for automatic accounts, make at least one administrator break-glass (with a second factor and a password kept safe) before a workspace requires single sign-on, and keep `pgkiln admin builder-sign-in password` for people with server access only.
|
|
244
|
+
13. Require two-factor sign-in of an application's users at least for administrators (Application settings → Sign-in methods → Two-factor sign-in → *Required for some roles*: `admin`); the Advisor warns when it is missing. Keep `PGKILN_SECRET_KEY` backed up: without it the authenticator apps stop working and users need their recovery codes or a reset.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------
|
|
2
|
+
-- 132: two-step sign-in for application users (sprint 130, SEC-3 part 1;
|
|
3
|
+
-- beyond APEX, which has no second factor for application accounts).
|
|
4
|
+
--
|
|
5
|
+
-- meta.account_factor: an account's second factors. This sprint: an
|
|
6
|
+
-- authenticator app (TOTP, RFC 6238), its secret encrypted with the
|
|
7
|
+
-- instance key (PGKILN_SECRET_KEY), like a developer's (128). last_step is
|
|
8
|
+
-- the last 30-second step a code was accepted for, so a code works once.
|
|
9
|
+
-- An account's factors serve every application it may use.
|
|
10
|
+
--
|
|
11
|
+
-- meta.account_recovery_code: ten one-time codes per account, as SHA-256
|
|
12
|
+
-- of the normalised code; confirmed: the user said they saved them.
|
|
13
|
+
--
|
|
14
|
+
-- meta.app.mfa, per application (exported with it):
|
|
15
|
+
-- off : no second step (the default)
|
|
16
|
+
-- optional : users who added an authenticator app give a code
|
|
17
|
+
-- required : every user gives a code; without an app, they add one while
|
|
18
|
+
-- signing in
|
|
19
|
+
-- roles : required for users with one of mfa_roles, optional for others
|
|
20
|
+
-- It covers the user directory's passwords and LDAP directories; an identity
|
|
21
|
+
-- provider (OpenID Connect, SAML) decides on its own second factor.
|
|
22
|
+
--
|
|
23
|
+
-- Owner-only, like meta.persistent_login: the runtime roles never read them.
|
|
24
|
+
-- Installation data: not exported with an application or a workspace.
|
|
25
|
+
-- ---------------------------------------------------------------------
|
|
26
|
+
create table meta.account_factor (
|
|
27
|
+
id int generated always as identity primary key,
|
|
28
|
+
account_id int not null references meta.account on delete cascade,
|
|
29
|
+
kind text not null check (kind in ('totp')),
|
|
30
|
+
name text not null check (length(name) between 1 and 60),
|
|
31
|
+
secret_enc text not null,
|
|
32
|
+
last_step bigint,
|
|
33
|
+
created_at timestamptz not null default now(),
|
|
34
|
+
last_used_at timestamptz
|
|
35
|
+
);
|
|
36
|
+
create index on meta.account_factor (account_id);
|
|
37
|
+
revoke all on meta.account_factor from public;
|
|
38
|
+
comment on table meta.account_factor is
|
|
39
|
+
'An application account''s second factors (132): authenticator apps (TOTP) with the secret encrypted with the instance key.';
|
|
40
|
+
|
|
41
|
+
create table meta.account_recovery_code (
|
|
42
|
+
account_id int not null references meta.account on delete cascade,
|
|
43
|
+
code_hash text not null,
|
|
44
|
+
confirmed boolean not null default false,
|
|
45
|
+
created_at timestamptz not null default now(),
|
|
46
|
+
used_at timestamptz,
|
|
47
|
+
primary key (account_id, code_hash)
|
|
48
|
+
);
|
|
49
|
+
revoke all on meta.account_recovery_code from public;
|
|
50
|
+
comment on table meta.account_recovery_code is
|
|
51
|
+
'One-time recovery codes of application accounts (132), as sha256 hex of the normalised code.';
|
|
52
|
+
|
|
53
|
+
alter table meta.app
|
|
54
|
+
add column mfa text not null default 'off' check (mfa in ('off', 'optional', 'required', 'roles')),
|
|
55
|
+
add column mfa_roles text[] not null default '{}';
|
|
56
|
+
comment on column meta.app.mfa is
|
|
57
|
+
'Two-step sign-in for the application''s users (132): off, optional, required, or roles (required for mfa_roles).';
|
|
58
|
+
comment on column meta.app.mfa_roles is 'Roles (lower case) that must use two-step sign-in when mfa = roles.';
|
|
@@ -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))
|
|
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,6 +300,11 @@ 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
|
+
- (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)).
|
|
307
|
+
APEX's application authentication schemes have no second factor.
|
|
303
308
|
- One RLS policy set protects both the web UI and the REST API.
|
|
304
309
|
- An import can be checked before it commits (`pgkiln import --check`): SQL that names a missing
|
|
305
310
|
table or column refuses the deployment instead of failing at run time.
|
|
@@ -410,6 +410,79 @@ opening another application of the group signs the user in there without the sig
|
|
|
410
410
|
|
|
411
411
|
Applications of different groups, and applications without a group, sign in on their own.
|
|
412
412
|
|
|
413
|
+
### Two-factor sign-in
|
|
414
|
+
|
|
415
|
+
APEX has no second factor for an application's own accounts: its documented route is an identity provider whose
|
|
416
|
+
sign-on policy asks for one, or a hand-written TOTP check in a custom authentication scheme. pgkiln has it built in.
|
|
417
|
+
**Application settings → Sign-in methods → Two-factor sign-in → Second step**:
|
|
418
|
+
|
|
419
|
+
| Setting | Who gives a code after the password |
|
|
420
|
+
|---|---|
|
|
421
|
+
| **Off** (the default) | Nobody |
|
|
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
|
+
| **Required for some roles** | Users with one of the roles in *Required for roles* (for example `admin, manager`); others as with *Optional* |
|
|
425
|
+
|
|
426
|
+
How it works:
|
|
427
|
+
|
|
428
|
+
- The second step follows the user directory's **password** and **LDAP** directories. An identity provider
|
|
429
|
+
([OpenID Connect](#single-sign-on-openid-connect), [SAML](#single-sign-on-saml-20)) asks for its own second factor;
|
|
430
|
+
database accounts, custom authentication and HTTP header authentication are not covered.
|
|
431
|
+
- The code comes from any authenticator app (TOTP, RFC 6238: Google Authenticator, Microsoft Authenticator,
|
|
432
|
+
1Password …), each code once, one 30-second step either side for clock drift. A **recovery code** (ten per account,
|
|
433
|
+
each usable once) works instead, and leads to *My account*, which says how many are left.
|
|
434
|
+
- Until the code is right there is no session: the pending sign-in waits five minutes (ten while adding an app) in
|
|
435
|
+
the signed-out session. Wrong codes count as failed sign-ins, so the usual lockout applies.
|
|
436
|
+
- An account's apps serve every application it uses, and each application decides whether it asks for them.
|
|
437
|
+
- **My account → Two-factor sign-in** adds an app (QR code or key, then its first code), shows new recovery codes,
|
|
438
|
+
and removes an app with a current code or a recovery code. The last app can't be removed while the application
|
|
439
|
+
requires a code of the user.
|
|
440
|
+
- A *Keep me signed in* or session-sharing sign-in skips the second step only when it was made with one (its method
|
|
441
|
+
ends in `+2fa`, shown in the Activity monitor); one made with just a password signs in again with the code.
|
|
442
|
+
- **Users → account → Two-factor sign-in** shows who has an app; **Reset two-factor sign-in** (with a reason, logged
|
|
443
|
+
as `2fa_reset`) removes the apps and recovery codes after a lost phone and signs the account out everywhere. Where
|
|
444
|
+
an application requires the step, the user adds a new app at the next sign-in.
|
|
445
|
+
- The apps' secrets are encrypted with the instance key `PGKILN_SECRET_KEY` (in `meta.account_factor`, readable by
|
|
446
|
+
the owner connection only); without that key *Required* can't be chosen, and the sign-in refuses rather than skips
|
|
447
|
+
the step. Recovery codes are stored as SHA-256 hashes.
|
|
448
|
+
- The **Advisor** warns when users with the role `admin` sign in with only a password.
|
|
449
|
+
|
|
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.
|
|
485
|
+
|
|
413
486
|
### What sign-in protects against
|
|
414
487
|
|
|
415
488
|
- **Brute force**: after 5 failed attempts for a username (or 50 from one IP address) within 15
|
|
@@ -418,13 +491,13 @@ Applications of different groups, and applications without a group, sign in on t
|
|
|
418
491
|
time.
|
|
419
492
|
- **Session fixation**: the session token is replaced at sign-in.
|
|
420
493
|
- **Stale sessions**: 60 minutes idle or 8 hours total (configurable).
|
|
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),
|
|
496
|
+
[SAML](#single-sign-on-saml-20)) the provider asks for its own. Developers have
|
|
497
|
+
[two-factor sign-in](03-builder.md#two-factor-sign-in) for the builder too.
|
|
421
498
|
|
|
422
499
|
All sign-ins, failures and lockouts appear in the **Activity** monitor.
|
|
423
500
|
|
|
424
|
-
A second factor for an application's users comes from the identity provider ([OpenID Connect](#single-sign-on-openid-connect),
|
|
425
|
-
[SAML](#single-sign-on-saml-20)). Developers can use an authenticator app for the builder itself:
|
|
426
|
-
[two-factor sign-in](03-builder.md#two-factor-sign-in).
|
|
427
|
-
|
|
428
501
|
## Authorization: what may the user do?
|
|
429
502
|
|
|
430
503
|
### Authorization schemes
|
|
@@ -440,6 +513,8 @@ Plus the built-in `MUST_NOT_BE_PUBLIC_USER` (signed in). Reference a scheme by n
|
|
|
440
513
|
`authz` property of a page, region, item, button, process, dynamic action or navigation entry;
|
|
441
514
|
prefix it with `!` to negate (`!ADMIN`). Unknown schemes **fail closed** (deny). Each scheme is
|
|
442
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)).
|
|
443
518
|
|
|
444
519
|
What happens when a scheme fails:
|
|
445
520
|
|
|
@@ -643,4 +718,5 @@ decision.
|
|
|
643
718
|
4. Checksum protection on pages (the default).
|
|
644
719
|
5. Business rules in constraints, triggers or functions, not only in validations.
|
|
645
720
|
6. Debug mode and debug messages off.
|
|
646
|
-
7.
|
|
721
|
+
7. [Two-factor sign-in](#two-factor-sign-in) required at least for administrators (the Advisor checks it).
|
|
722
|
+
8. Review the app's **Settings → Security checklist** and **Activity** monitor now and then.
|
|
@@ -337,6 +337,7 @@ All in schema `meta`. `id` columns are generated; `seq` orders siblings (default
|
|
|
337
337
|
| `sso_providers` | text[] | Names of identity providers (OpenID Connect or SAML) offered on the login page |
|
|
338
338
|
| `ldap_directories` | text[] | LDAP directories the password form checks, after local accounts |
|
|
339
339
|
| `remember_me_days` | int | Days a "Keep me signed in" sign-in lasts; NULL = not offered |
|
|
340
|
+
| `mfa`, `mfa_roles` | text, text[] | [Two-factor sign-in](08-security.md#two-factor-sign-in): `off`, `optional`, `required` or `roles` (required for `mfa_roles`) |
|
|
340
341
|
| `pwa`, `pwa_short_name`, `pwa_icon`, `pwa_offline_pages`, `pwa_offline_submit` | boolean, text, bytea, boolean, boolean | Progressive Web App ([chapter 17](17-mobile.md)) |
|
|
341
342
|
| `db_role` | text | Database role every request runs as |
|
|
342
343
|
| `api_role` | text | Database role of REST API tokens for this app (PostgREST switches to it) |
|
|
@@ -510,7 +511,8 @@ navigation entries and application processes have the same `build_option` column
|
|
|
510
511
|
| `supporting_script` | Supporting objects per app (see above) | no |
|
|
511
512
|
| `document_template` | Document templates per app: `name`, `description`, `query`, `template`, `layout`, `filename`, `authz` | yes (read) |
|
|
512
513
|
| `ldap_identity` | Links an account to a directory entry (`directory_id`, `subject` = entryUUID or DN) | no |
|
|
513
|
-
| `persistent_login` | "Keep me signed in" tokens: `token_hash`, `account_id`, `app_id`, `groups`, `method
|
|
514
|
+
| `persistent_login` | "Keep me signed in" tokens: `token_hash`, `account_id`, `app_id`, `groups`, `method` (`+2fa` after a second step), `expires_at` | no |
|
|
515
|
+
| `account_factor`, `account_recovery_code` | An account's authenticator apps (secret encrypted with the instance key) and recovery codes (SHA-256) | no |
|
|
514
516
|
| `instance_setting` | Secrets, e.g. the URL checksum key | no |
|
|
515
517
|
| `setting` | Account settings: `password_min_length`, `password_require_mixed`, `password_lifetime_days` | yes (read) |
|
|
516
518
|
| `text_message` | Per app: `name`, `language`, `text` | yes |
|
|
@@ -609,6 +611,8 @@ pgkiln's icons; its filter box also searches the Lucide icons by name and search
|
|
|
609
611
|
| any page `?doc=NAME` | Download a document template filled with the page's values |
|
|
610
612
|
| `POST /a/:alias/account/devices` | Sign out on all devices ("Keep me signed in") |
|
|
611
613
|
| `POST /a/:alias/password` | Change an expired password while signing in |
|
|
614
|
+
| `GET/POST /a/:alias/login/2fa`, `POST /a/:alias/login/2fa/done` | The second step of signing in: a code, or adding the first app and seeing the recovery codes |
|
|
615
|
+
| `POST /a/:alias/account/2fa/start`, `…/cancel`, `…/confirm`, `…/codes`, `…/codes/confirm`, `…/:id/delete` | My account → Two-factor sign-in |
|
|
612
616
|
| `GET/POST /a/:alias/account`, `POST /a/:alias/account/password`, `POST /a/:alias/account/theme`, `POST /a/:alias/account/style` | My account, own password, the light/dark switch, the [style variant](14-globalization.md#style-variants-theme-roller) switch |
|
|
613
617
|
| `POST /a/:alias/tz` | The browser's time zone for the session (automatic time zone; sent by `app.js`, CSRF token required) |
|
|
614
618
|
| any page `?lang=xx` | Switch the language for the session |
|
|
@@ -98,7 +98,8 @@ APEX's "Social Sign-In" scheme, with identity-provider groups mapped to applicat
|
|
|
98
98
|
in to a second app is then silent via the identity provider's session. See
|
|
99
99
|
[chapter 8](08-security.md#single-sign-on-openid-connect). **SAML 2.0** providers and **LDAP /
|
|
100
100
|
Active Directory** passwords work the same way (groups → roles), and *Keep me signed in* matches
|
|
101
|
-
APEX's persistent authentication.
|
|
101
|
+
APEX's persistent authentication. Unlike APEX, the user directory's accounts can have
|
|
102
|
+
[two-factor sign-in](08-security.md#two-factor-sign-in) (an authenticator app), per application and per role. **Database accounts** sign in with PostgreSQL login roles, and
|
|
102
103
|
**custom authentication** calls your own PL/pgSQL function (APEX: a custom PL/SQL scheme), see
|
|
103
104
|
[chapter 8](08-security.md#custom-authentication-a-plpgsql-function). The **login page** (APEX: page 9999 with
|
|
104
105
|
the Login region template) is a page you design in the Page Designer around a **Login** region, and
|
|
@@ -62,6 +62,13 @@ src/
|
|
|
62
62
|
dbauth.ts database-account authentication: role lists, a short connection as the role (DATABASE_URL target), membership/superuser checks
|
|
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
|
+
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 and the
|
|
67
|
+
session's one-time challenge)
|
|
68
|
+
account-mfa.ts (sprint 130) two-factor sign-in for application accounts: the application's rule (off, optional,
|
|
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)
|
|
65
72
|
workflow.ts workflows: step checks, the runner with parallel branches (NOTIFY + polling), invoke_api steps
|
|
66
73
|
(the call between two transactions, with a lease), Advisor references, the diagram;
|
|
67
74
|
(sprint 114) send_mail and draft steps, switch cases on task outcomes, the diagram as a standalone SVG
|
|
@@ -171,8 +178,13 @@ src/
|
|
|
171
178
|
routes.ts HTTP handlers: show, submit, dynamic actions, cascading lists, login
|
|
172
179
|
signin.ts the sign-in form (built-in page and the login region of an application's own login page), safeNext, passwordResetAvailable
|
|
173
180
|
password-reset.ts Forgot password: request (same answer either way), the link (token into the session), the new password
|
|
181
|
+
two-step.ts (sprint 130) the second step of signing in (/login/2fa: a code, or the first app and its recovery
|
|
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)
|
|
174
185
|
context.ts PageContext, dbg()/timed() debug helpers, bind values, substitutions, public error messages, writeOut (streamed responses with back pressure)
|
|
175
|
-
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=)
|
|
176
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
|
|
177
189
|
processes.ts download (file or zip from a query, safe headers), workflow processes, configuration checks of chains
|
|
178
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
package/public/app.css
CHANGED
|
@@ -2164,3 +2164,13 @@ body.nav-pillar-dark .t-nav details:has(> summary.in-trail) > ul { border-inline
|
|
|
2164
2164
|
.region[aria-busy="true"]::after { animation: busy-slide 1.2s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; }
|
|
2165
2165
|
}
|
|
2166
2166
|
@keyframes busy-slide { from { background-position: -60% 0, 0 0; } to { background-position: 160% 0, 0 0; } }
|
|
2167
|
+
|
|
2168
|
+
/* (sprint 130) two-step sign-in: the QR code for an authenticator app, its key and the recovery codes */
|
|
2169
|
+
.mfa-qr { margin: .5rem 0 1rem; }
|
|
2170
|
+
.mfa-qr svg { width: 200px; max-width: 100%; height: auto; background: #fff; padding: 8px; border-radius: 6px; }
|
|
2171
|
+
.mfa-secret { word-break: break-all; letter-spacing: .05em; }
|
|
2172
|
+
.mfa-steps { padding-left: 1.25rem; margin: 0; }
|
|
2173
|
+
.mfa-codes { columns: 2; column-gap: 2rem; padding-left: 1.5rem; }
|
|
2174
|
+
.mfa-factors { list-style: none; padding: 0; margin: 0 0 .75rem; }
|
|
2175
|
+
.mfa-factors li { padding: .4rem 0; border-bottom: 1px solid var(--border); }
|
|
2176
|
+
@media (max-width: 480px) { .mfa-codes { columns: 1; } }
|
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:
|
|
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.
|