pgkiln 0.65.0 → 0.66.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/db/migrations/134_idp_mfa.sql +46 -0
- package/docs/apex-feature-parity.md +5 -1
- package/docs/guide/03-builder.md +5 -1
- package/docs/guide/08-security.md +41 -3
- package/package.json +1 -1
- package/public/builder.css +6 -0
- package/src/builder/i18n/de.ts +13 -0
- package/src/builder/i18n/en.ts +13 -0
- package/src/builder/i18n/es.ts +13 -0
- package/src/builder/i18n/fr.ts +13 -0
- package/src/builder/i18n/nl.ts +13 -0
- package/src/builder/i18n/pt.ts +13 -0
- package/src/builder/login-page.ts +14 -16
- package/src/builder/routes.ts +24 -8
- package/src/builder/users.ts +24 -5
- package/src/runtime/routes.ts +39 -3
- package/src/runtime/two-step.ts +21 -1
- package/src/saml.ts +34 -11
- package/src/sso.ts +72 -11
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------
|
|
2
|
+
-- 134: make sure an identity provider really did multi-factor sign-in
|
|
3
|
+
-- (sprint 132, SEC-4; beyond APEX, which trusts the IdP's sign-on policy).
|
|
4
|
+
--
|
|
5
|
+
-- meta.auth_provider:
|
|
6
|
+
-- mfa_required refuse a sign-in (to applications and to the builder)
|
|
7
|
+
-- unless the provider says it asked for a second factor:
|
|
8
|
+
-- OpenID Connect through the ID token's amr or acr claim,
|
|
9
|
+
-- SAML through the assertion's AuthnContextClassRef.
|
|
10
|
+
-- mfa_request what to ask the provider for, space separated: OpenID
|
|
11
|
+
-- Connect acr_values, or SAML AuthnContextClassRef values
|
|
12
|
+
-- (a RequestedAuthnContext). null: ask for nothing.
|
|
13
|
+
-- mfa_values the amr / acr / AuthnContextClassRef values that count
|
|
14
|
+
-- as a second factor (any one of them is enough).
|
|
15
|
+
-- local_second_step after a sign-in through this provider, pgkiln asks its
|
|
16
|
+
-- own second step as after a password (the application's
|
|
17
|
+
-- two-step rule; the developer's factor for the builder),
|
|
18
|
+
-- for providers that cannot enforce MFA themselves.
|
|
19
|
+
--
|
|
20
|
+
-- meta.sso_pending.stepup_session: a step-up through the provider (an
|
|
21
|
+
-- authorization scheme with mfa_minutes, 133): the signed-in session the
|
|
22
|
+
-- provider's fresh multi-factor sign-in confirms. The provider is asked to
|
|
23
|
+
-- sign the user in again (prompt=login / max_age=0, SAML ForceAuthn).
|
|
24
|
+
-- ---------------------------------------------------------------------
|
|
25
|
+
alter table meta.auth_provider
|
|
26
|
+
add column mfa_required boolean not null default false,
|
|
27
|
+
add column mfa_request text check (length(mfa_request) <= 500),
|
|
28
|
+
add column mfa_values text[] not null default array[
|
|
29
|
+
'mfa',
|
|
30
|
+
'http://schemas.microsoft.com/claims/multipleauthn',
|
|
31
|
+
'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract',
|
|
32
|
+
'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorUnregistered'],
|
|
33
|
+
add column local_second_step boolean not null default false;
|
|
34
|
+
comment on column meta.auth_provider.mfa_required is
|
|
35
|
+
'Refuse sign-ins the provider does not mark as multi-factor (amr / acr, SAML AuthnContextClassRef; 134).';
|
|
36
|
+
comment on column meta.auth_provider.mfa_request is
|
|
37
|
+
'acr_values (OpenID Connect) or AuthnContextClassRef values (SAML) to request, space separated (134).';
|
|
38
|
+
comment on column meta.auth_provider.mfa_values is
|
|
39
|
+
'amr / acr / AuthnContextClassRef values that count as a second factor (134).';
|
|
40
|
+
comment on column meta.auth_provider.local_second_step is
|
|
41
|
+
'After a sign-in through this provider, pgkiln asks its own second step too (134).';
|
|
42
|
+
|
|
43
|
+
alter table meta.sso_pending
|
|
44
|
+
add column stepup_session uuid references meta.session (id) on delete cascade;
|
|
45
|
+
comment on column meta.sso_pending.stepup_session is
|
|
46
|
+
'A step-up through the provider: the signed-in session it confirms (134).';
|
|
@@ -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; 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)) |
|
|
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)). Sprint 132: pgkiln checks that the identity provider did multi-factor sign-in (`amr` / `acr`, SAML `AuthnContextClassRef`), requests it, steps up through the provider, or asks its own second step after it ([chapter 8](guide/08-security.md#multi-factor-sign-in-at-the-identity-provider)) |
|
|
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) |
|
|
@@ -305,6 +305,10 @@ Small but real differences, for teams comparing the two:
|
|
|
305
305
|
before sensitive pages (an authorization scheme's *Second step within*) and REST API tokens only after a second step
|
|
306
306
|
([chapter 8](guide/08-security.md#two-factor-sign-in)).
|
|
307
307
|
APEX's application authentication schemes have no second factor.
|
|
308
|
+
- (sprint 132) **Proof that the identity provider did multi-factor sign-in**: a provider can be required to say so in
|
|
309
|
+
the ID token's `amr` / `acr` or the SAML `AuthnContextClassRef`, step-up goes back to it for a fresh multi-factor
|
|
310
|
+
sign-in, and pgkiln's own second step can follow a provider that cannot enforce MFA
|
|
311
|
+
([chapter 8](guide/08-security.md#multi-factor-sign-in-at-the-identity-provider)). APEX trusts the provider's policy.
|
|
308
312
|
- One RLS policy set protects both the web UI and the REST API.
|
|
309
313
|
- An import can be checked before it commits (`pgkiln import --check`): SQL that names a missing
|
|
310
314
|
table or column refuses the deployment instead of failing at run time.
|
package/docs/guide/03-builder.md
CHANGED
|
@@ -138,7 +138,11 @@ started it (in the same browser). The region lists linked identities, with **Unl
|
|
|
138
138
|
developers linked to it; administrators unlink any of them.
|
|
139
139
|
|
|
140
140
|
Signing in through a provider skips pgkiln's own second step and the enrolment on Security: the provider decides on
|
|
141
|
-
the second factor.
|
|
141
|
+
the second factor. (0.66) The provider's *Require multi-factor sign-in at this provider* makes pgkiln check that it
|
|
142
|
+
did (the ID token's `amr` / `acr`, SAML's `AuthnContextClassRef`) and refuse the sign-in otherwise; *Ask pgkiln's own
|
|
143
|
+
second step after this provider* makes a developer with a second factor give it after the provider too
|
|
144
|
+
([chapter 8](08-security.md#multi-factor-sign-in-at-the-identity-provider)). A directory checks a password, so
|
|
145
|
+
pgkiln's second step follows as with the pgkiln password.
|
|
142
146
|
|
|
143
147
|
**Requiring it per workspace.** A workspace's page (*Preferences* → *Single sign-on*) has *This workspace's
|
|
144
148
|
developers must sign in through an identity provider* (or a directory). Its developers' pgkiln password and a passkey
|
|
@@ -244,6 +244,40 @@ psql "$DATABASE_URL" -f examples/keycloak-saml.sql # reads Keycloak's signing
|
|
|
244
244
|
|
|
245
245
|
The HR login page then offers *Sign in with Keycloak (SAML)*, with the same Keycloak users.
|
|
246
246
|
|
|
247
|
+
### Multi-factor sign-in at the identity provider
|
|
248
|
+
|
|
249
|
+
With single sign-on the provider asks for the second factor, in its own sign-on policy (Entra ID Conditional Access,
|
|
250
|
+
Okta and Keycloak authentication flows, OCI IAM sign-on policies). APEX trusts that policy blindly; pgkiln (0.66) can
|
|
251
|
+
check that the provider really did it. On the provider (**Users → Identity providers**), under *Multi-factor sign-in*:
|
|
252
|
+
|
|
253
|
+
| Field | Meaning |
|
|
254
|
+
|---|---|
|
|
255
|
+
| Require multi-factor sign-in at this provider | Refuse every sign-in through this provider, to applications and to the builder, that the provider does not mark as multi-factor. The user sees *The identity provider did not confirm a second factor*; the Activity monitor logs it as a failed sign-in |
|
|
256
|
+
| Ask the provider for | Sent with every sign-in: OpenID Connect `acr_values`, or SAML `AuthnContextClassRef` values (a `RequestedAuthnContext`, comparison *exact*), separated by spaces. Empty: nothing is asked for |
|
|
257
|
+
| Values that mean multi-factor | The values that count, separated by spaces; any one is enough. OpenID Connect: an entry of the ID token's `amr` claim (RFC 8176) or its `acr`. SAML: the assertion's `AuthnContextClassRef` |
|
|
258
|
+
| Ask pgkiln's own second step after this provider | For providers that cannot require MFA themselves: see below |
|
|
259
|
+
|
|
260
|
+
The default values are `mfa` (Entra ID, Okta and Auth0 put it in `amr` after a second factor), Entra ID's SAML
|
|
261
|
+
`http://schemas.microsoft.com/claims/multipleauthn`, and SAML's `MobileTwoFactorContract` and
|
|
262
|
+
`MobileTwoFactorUnregistered`. For Keycloak, name the level of your step-up flow (for example request and accept
|
|
263
|
+
`gold`, or `2` with the default level-of-authentication mapping); for a SAML IdP, the class it sends after a second
|
|
264
|
+
factor. Without *Require*, a provider that does say `mfa` still counts: the session then has a recent second step for
|
|
265
|
+
[step-up](#asking-again-before-a-sensitive-page-step-up), and the Activity monitor shows `sso:<provider> (mfa)`.
|
|
266
|
+
|
|
267
|
+
**Step-up through the provider.** A user who signed in through a provider and opens a page behind an authorization
|
|
268
|
+
scheme with *Second step within (minutes)* goes back to that provider, which is asked to sign them in again
|
|
269
|
+
(OpenID Connect `prompt=login` and `max_age=0`; SAML `ForceAuthn`) with the requested context. The answer must carry
|
|
270
|
+
one of the multi-factor values, a sign-in time (`auth_time`, or the SAML `AuthnInstant`) of the last ten minutes,
|
|
271
|
+
and the identity already linked to the signed-in account; nothing is created or linked on the way. The page then
|
|
272
|
+
opens.
|
|
273
|
+
|
|
274
|
+
**pgkiln's own second step after the provider.** With *Ask pgkiln's own second step after this provider* on, a sign-in
|
|
275
|
+
through the provider is followed by the application's [two-factor rule](#two-factor-sign-in) exactly as after a
|
|
276
|
+
password (users add an app or a passkey on *My account*, or while signing in when the application requires one), and
|
|
277
|
+
step-up asks for that factor instead of going back to the provider. For the builder, a developer with a second factor
|
|
278
|
+
gives it after the provider; one without is treated like a password sign-in, so a two-factor rule of the instance or
|
|
279
|
+
workspace makes them add one.
|
|
280
|
+
|
|
247
281
|
### LDAP and Active Directory
|
|
248
282
|
|
|
249
283
|
Applications can check passwords against **LDAP directories** (OpenLDAP, Active Directory,
|
|
@@ -426,7 +460,8 @@ sign-on policy asks for one, or a hand-written TOTP check in a custom authentica
|
|
|
426
460
|
How it works:
|
|
427
461
|
|
|
428
462
|
- 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
|
|
463
|
+
([OpenID Connect](#single-sign-on-openid-connect), [SAML](#single-sign-on-saml-20)) asks for its own second factor,
|
|
464
|
+
which pgkiln can [check](#multi-factor-sign-in-at-the-identity-provider), or leaves it to pgkiln's second step;
|
|
430
465
|
database accounts, custom authentication and HTTP header authentication are not covered.
|
|
431
466
|
- The code comes from any authenticator app (TOTP, RFC 6238: Google Authenticator, Microsoft Authenticator,
|
|
432
467
|
1Password …), each code once, one 30-second step either side for clock drift. A **recovery code** (ten per account,
|
|
@@ -472,7 +507,9 @@ An authorization scheme's **Second step within (minutes)** makes it pass only wh
|
|
|
472
507
|
the scheme otherwise to *Confirm it's you* (the second-step page) and back to the page afterwards; regions, buttons and
|
|
473
508
|
processes behind it stay hidden or skipped until then. A user without a factor is told to add one on *My account*.
|
|
474
509
|
Typical: `SALARY_EDIT` (role `hr_manager`, second step within 15 minutes) on the page that changes salaries. A
|
|
475
|
-
*Keep me signed in
|
|
510
|
+
*Keep me signed in* or shared session has no recent second step of pgkiln's own, so it is asked too; a single sign-on
|
|
511
|
+
session goes back to its [identity provider](#multi-factor-sign-in-at-the-identity-provider) for a new multi-factor
|
|
512
|
+
sign-in (or to pgkiln's own second step, when the provider leaves it to pgkiln).
|
|
476
513
|
A form posted after the time ran out is not processed: the user confirms, comes back to the page and submits again.
|
|
477
514
|
|
|
478
515
|
#### REST API tokens
|
|
@@ -493,7 +530,8 @@ application users, so nothing to cover there.
|
|
|
493
530
|
- **Stale sessions**: 60 minutes idle or 8 hours total (configurable).
|
|
494
531
|
- **Stolen passwords**: [two-factor sign-in](#two-factor-sign-in) asks for a code from an authenticator app or a
|
|
495
532
|
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
|
|
533
|
+
[SAML](#single-sign-on-saml-20)) the provider asks for its own, and pgkiln
|
|
534
|
+
[checks that it did](#multi-factor-sign-in-at-the-identity-provider). Developers have
|
|
497
535
|
[two-factor sign-in](03-builder.md#two-factor-sign-in) for the builder too.
|
|
498
536
|
|
|
499
537
|
All sign-ins, failures and lockouts appear in the **Activity** monitor.
|
package/package.json
CHANGED
package/public/builder.css
CHANGED
|
@@ -1576,3 +1576,9 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
1576
1576
|
.mfa-steps { padding-left: 1.25rem; }
|
|
1577
1577
|
.mfa-codes { columns: 2; column-gap: 2rem; padding-left: 1.5rem; font-size: 1.05em; }
|
|
1578
1578
|
@media (max-width: 480px) { .mfa-codes { columns: 1; } }
|
|
1579
|
+
|
|
1580
|
+
/* Settings → Sign-in: a checkbox under a grid of fields gets room, and long help stays readable */
|
|
1581
|
+
.settings-panel[data-tab="signin"] .form-grid + .field { margin-top: 0.75rem; }
|
|
1582
|
+
.settings-panel[data-tab="signin"] .field > .help,
|
|
1583
|
+
.settings-panel[data-tab="signin"] p.small { max-width: 75ch; }
|
|
1584
|
+
.settings-panel[data-tab="signin"] h3:not(:first-child) { margin-top: 1.5rem; }
|
package/src/builder/i18n/de.ts
CHANGED
|
@@ -1295,6 +1295,9 @@ export const de: BuilderMessages = {
|
|
|
1295
1295
|
'st.custom_post_help': 'Optionales PL/pgSQL nach einer erfolgreichen Prüfung, mit p_username (und meta.app_user()), z. B. um die letzte Anmeldung zu speichern. Eine Exception lehnt die Anmeldung ab. Anwendungsprozesse „after login“ laufen danach wie gewohnt.',
|
|
1296
1296
|
'st.signin': 'Anmeldung',
|
|
1297
1297
|
'st.methods': 'Anmeldemethoden',
|
|
1298
|
+
'st.signin_page': 'Anmeldeseite',
|
|
1299
|
+
'st.staying': 'Angemeldet bleiben',
|
|
1300
|
+
'st.two_factor': 'Zwei-Faktor-Anmeldung',
|
|
1298
1301
|
'st.local': 'Benutzername und Passwort',
|
|
1299
1302
|
'st.ldap': 'Passwörter aus LDAP: {name}',
|
|
1300
1303
|
'st.disabled': '(deaktiviert)',
|
|
@@ -1509,6 +1512,16 @@ export const de: BuilderMessages = {
|
|
|
1509
1512
|
'idp.disabled_s': 'deaktiviert',
|
|
1510
1513
|
'idp.auto_s': 'automatisch anlegen',
|
|
1511
1514
|
'idp.links_s': 'verknüpft vorhandene Konten',
|
|
1515
|
+
'idp.mfa_title': 'Mehrstufige Anmeldung',
|
|
1516
|
+
'idp.mfa_required': 'Mehrstufige Anmeldung bei diesem Anbieter verlangen',
|
|
1517
|
+
'idp.mfa_required_help': 'Anmeldungen (bei Anwendungen und im Builder) ablehnen, die der Anbieter nicht als mehrstufig kennzeichnet: bei OpenID Connect über den amr- oder acr-Claim des ID-Tokens, bei SAML über die AuthnContextClassRef.',
|
|
1518
|
+
'idp.mfa_request': 'Beim Anbieter anfordern',
|
|
1519
|
+
'idp.mfa_request_help': 'OpenID-Connect-acr_values oder SAML-AuthnContextClassRef-Werte, durch Leerzeichen getrennt. Leer: nichts anfordern.',
|
|
1520
|
+
'idp.mfa_values': 'Werte, die mehrstufig bedeuten',
|
|
1521
|
+
'idp.mfa_values_help': 'amr-, acr- oder AuthnContextClassRef-Werte, durch Leerzeichen getrennt; einer davon genügt. Entra ID und Okta setzen „mfa“ in amr.',
|
|
1522
|
+
'idp.local_step': 'Nach diesem Anbieter den eigenen zweiten Schritt von pgkiln verlangen',
|
|
1523
|
+
'idp.local_step_help': 'Für Anbieter, die keine mehrstufige Anmeldung erzwingen können: Die Zwei-Schritt-Regel der Anwendung gilt wie nach einem Passwort, und Entwickler mit einem zweiten Faktor geben ihn ebenfalls an. Eine erneute Bestätigung fragt dann nach dem Faktor in pgkiln, statt sich erneut beim Anbieter anzumelden.',
|
|
1524
|
+
'idp.mfa_s': 'verlangt MFA',
|
|
1512
1525
|
'idp.none': 'Noch keine Identitätsanbieter.',
|
|
1513
1526
|
'idp.settings': 'Einstellungen',
|
|
1514
1527
|
'idp.delete_confirm': '{name} löschen? Verknüpfte Identitäten werden entfernt; die Konten bleiben.',
|
package/src/builder/i18n/en.ts
CHANGED
|
@@ -1298,6 +1298,9 @@ export const en = {
|
|
|
1298
1298
|
'st.custom_post_help': 'Optional PL/pgSQL run after a successful check, with p_username (and meta.app_user()), e.g. to record the last sign-in. Raising an exception refuses the sign-in. Application processes "after login" run afterwards as usual.',
|
|
1299
1299
|
'st.signin': 'Sign-in',
|
|
1300
1300
|
'st.methods': 'Sign-in methods',
|
|
1301
|
+
'st.signin_page': 'Sign-in page',
|
|
1302
|
+
'st.staying': 'Staying signed in',
|
|
1303
|
+
'st.two_factor': 'Two-factor sign-in',
|
|
1301
1304
|
'st.local': 'Username and password',
|
|
1302
1305
|
'st.ldap': 'Passwords from LDAP: {name}',
|
|
1303
1306
|
'st.disabled': '(disabled)',
|
|
@@ -1512,6 +1515,16 @@ export const en = {
|
|
|
1512
1515
|
'idp.disabled_s': 'disabled',
|
|
1513
1516
|
'idp.auto_s': 'auto-create',
|
|
1514
1517
|
'idp.links_s': 'links existing accounts',
|
|
1518
|
+
'idp.mfa_title': 'Multi-factor sign-in',
|
|
1519
|
+
'idp.mfa_required': 'Require multi-factor sign-in at this provider',
|
|
1520
|
+
'idp.mfa_required_help': "Refuse sign-ins (to applications and to the builder) that the provider does not mark as multi-factor: OpenID Connect through the ID token's amr or acr claim, SAML through the AuthnContextClassRef.",
|
|
1521
|
+
'idp.mfa_request': 'Ask the provider for',
|
|
1522
|
+
'idp.mfa_request_help': 'OpenID Connect acr_values or SAML AuthnContextClassRef values, separated by spaces. Empty: ask for nothing.',
|
|
1523
|
+
'idp.mfa_values': 'Values that mean multi-factor',
|
|
1524
|
+
'idp.mfa_values_help': 'amr, acr or AuthnContextClassRef values, separated by spaces; any one of them is enough. Entra ID and Okta put "mfa" in amr.',
|
|
1525
|
+
'idp.local_step': "Ask pgkiln's own second step after this provider",
|
|
1526
|
+
'idp.local_step_help': "For providers that cannot require multi-factor sign-in: the application's two-step rule applies as after a password, and developers with a second factor give it too. Step-up then asks for pgkiln's factor instead of signing in at the provider again.",
|
|
1527
|
+
'idp.mfa_s': 'requires MFA',
|
|
1515
1528
|
'idp.none': 'No identity providers yet.',
|
|
1516
1529
|
'idp.settings': 'Settings',
|
|
1517
1530
|
'idp.delete_confirm': 'Delete {name}? Linked identities are removed; accounts stay.',
|
package/src/builder/i18n/es.ts
CHANGED
|
@@ -1295,6 +1295,9 @@ export const es: BuilderMessages = {
|
|
|
1295
1295
|
'st.custom_post_help': 'PL/pgSQL opcional que se ejecuta tras una comprobación correcta, con p_username (y meta.app_user()), p. ej. para registrar el último inicio de sesión. Lanzar una excepción rechaza el inicio de sesión. Los procesos de aplicación «after login» se ejecutan después como siempre.',
|
|
1296
1296
|
'st.signin': 'Inicio de sesión',
|
|
1297
1297
|
'st.methods': 'Métodos de inicio de sesión',
|
|
1298
|
+
'st.signin_page': 'Página de inicio de sesión',
|
|
1299
|
+
'st.staying': 'Mantener la sesión',
|
|
1300
|
+
'st.two_factor': 'Inicio de sesión en dos pasos',
|
|
1298
1301
|
'st.local': 'Nombre de usuario y contraseña',
|
|
1299
1302
|
'st.ldap': 'Contraseñas de LDAP: {name}',
|
|
1300
1303
|
'st.disabled': '(desactivado)',
|
|
@@ -1509,6 +1512,16 @@ export const es: BuilderMessages = {
|
|
|
1509
1512
|
'idp.disabled_s': 'desactivado',
|
|
1510
1513
|
'idp.auto_s': 'creación automática',
|
|
1511
1514
|
'idp.links_s': 'vincula cuentas existentes',
|
|
1515
|
+
'idp.mfa_title': 'Inicio de sesión multifactor',
|
|
1516
|
+
'idp.mfa_required': 'Exigir inicio de sesión multifactor en este proveedor',
|
|
1517
|
+
'idp.mfa_required_help': 'Rechaza los inicios de sesión (en aplicaciones y en el builder) que el proveedor no marca como multifactor: en OpenID Connect por el claim amr o acr del token de ID, en SAML por el AuthnContextClassRef.',
|
|
1518
|
+
'idp.mfa_request': 'Solicitar al proveedor',
|
|
1519
|
+
'idp.mfa_request_help': 'Valores acr_values de OpenID Connect o AuthnContextClassRef de SAML, separados por espacios. Vacío: no solicitar nada.',
|
|
1520
|
+
'idp.mfa_values': 'Valores que significan multifactor',
|
|
1521
|
+
'idp.mfa_values_help': 'Valores amr, acr o AuthnContextClassRef, separados por espacios; basta con uno. Entra ID y Okta ponen «mfa» en amr.',
|
|
1522
|
+
'idp.local_step': 'Pedir el segundo paso propio de pgkiln después de este proveedor',
|
|
1523
|
+
'idp.local_step_help': 'Para proveedores que no pueden exigir multifactor: la regla de dos pasos de la aplicación se aplica como después de una contraseña, y los desarrolladores con un segundo factor también lo indican. La nueva confirmación pide entonces el factor de pgkiln en lugar de volver a iniciar sesión en el proveedor.',
|
|
1524
|
+
'idp.mfa_s': 'exige MFA',
|
|
1512
1525
|
'idp.none': 'Todavía no hay proveedores de identidad.',
|
|
1513
1526
|
'idp.settings': 'Ajustes',
|
|
1514
1527
|
'idp.delete_confirm': '¿Eliminar {name}? Se quitan las identidades vinculadas; las cuentas se mantienen.',
|
package/src/builder/i18n/fr.ts
CHANGED
|
@@ -1295,6 +1295,9 @@ export const fr: BuilderMessages = {
|
|
|
1295
1295
|
'st.custom_post_help': 'PL/pgSQL facultatif exécuté après une vérification réussie, avec p_username (et meta.app_user()), p. ex. pour enregistrer la dernière connexion. Lever une exception refuse la connexion. Les processus d’application « after login » s’exécutent ensuite comme d’habitude.',
|
|
1296
1296
|
'st.signin': 'Connexion',
|
|
1297
1297
|
'st.methods': 'Méthodes de connexion',
|
|
1298
|
+
'st.signin_page': 'Page de connexion',
|
|
1299
|
+
'st.staying': 'Rester connecté',
|
|
1300
|
+
'st.two_factor': 'Connexion en deux étapes',
|
|
1298
1301
|
'st.local': 'Nom d’utilisateur et mot de passe',
|
|
1299
1302
|
'st.ldap': 'Mots de passe LDAP : {name}',
|
|
1300
1303
|
'st.disabled': '(désactivé)',
|
|
@@ -1509,6 +1512,16 @@ export const fr: BuilderMessages = {
|
|
|
1509
1512
|
'idp.disabled_s': 'désactivé',
|
|
1510
1513
|
'idp.auto_s': 'création automatique',
|
|
1511
1514
|
'idp.links_s': 'lie les comptes existants',
|
|
1515
|
+
'idp.mfa_title': 'Connexion multifacteur',
|
|
1516
|
+
'idp.mfa_required': 'Exiger une connexion multifacteur chez ce fournisseur',
|
|
1517
|
+
'idp.mfa_required_help': 'Refuser les connexions (aux applications et au builder) que le fournisseur ne marque pas comme multifacteur : en OpenID Connect par le claim amr ou acr du jeton d’identité, en SAML par l’AuthnContextClassRef.',
|
|
1518
|
+
'idp.mfa_request': 'Demander au fournisseur',
|
|
1519
|
+
'idp.mfa_request_help': 'Valeurs acr_values (OpenID Connect) ou AuthnContextClassRef (SAML), séparées par des espaces. Vide : ne rien demander.',
|
|
1520
|
+
'idp.mfa_values': 'Valeurs qui signifient multifacteur',
|
|
1521
|
+
'idp.mfa_values_help': 'Valeurs amr, acr ou AuthnContextClassRef, séparées par des espaces ; une seule suffit. Entra ID et Okta mettent « mfa » dans amr.',
|
|
1522
|
+
'idp.local_step': 'Demander la deuxième étape de pgkiln après ce fournisseur',
|
|
1523
|
+
'idp.local_step_help': 'Pour les fournisseurs qui ne peuvent pas exiger le multifacteur : la règle de connexion en deux étapes de l’application s’applique comme après un mot de passe, et les développeurs ayant un second facteur le donnent aussi. Une nouvelle confirmation demande alors le facteur de pgkiln au lieu de se reconnecter chez le fournisseur.',
|
|
1524
|
+
'idp.mfa_s': 'exige la MFA',
|
|
1512
1525
|
'idp.none': 'Pas encore de fournisseurs d’identité.',
|
|
1513
1526
|
'idp.settings': 'Paramètres',
|
|
1514
1527
|
'idp.delete_confirm': 'Supprimer {name} ? Les identités liées sont retirées ; les comptes restent.',
|
package/src/builder/i18n/nl.ts
CHANGED
|
@@ -1295,6 +1295,9 @@ export const nl: BuilderMessages = {
|
|
|
1295
1295
|
'st.custom_post_help': 'Optionele PL/pgSQL die draait na een geslaagde controle, met p_username (en meta.app_user()), bijv. om de laatste aanmelding vast te leggen. Een exception weigert de aanmelding. Applicatieprocessen “after login” draaien daarna zoals gewoonlijk.',
|
|
1296
1296
|
'st.signin': 'Aanmelden',
|
|
1297
1297
|
'st.methods': 'Aanmeldmethoden',
|
|
1298
|
+
'st.signin_page': 'Aanmeldpagina',
|
|
1299
|
+
'st.staying': 'Aangemeld blijven',
|
|
1300
|
+
'st.two_factor': 'Aanmelden in twee stappen',
|
|
1298
1301
|
'st.local': 'Gebruikersnaam en wachtwoord',
|
|
1299
1302
|
'st.ldap': 'Wachtwoorden uit LDAP: {name}',
|
|
1300
1303
|
'st.disabled': '(uitgeschakeld)',
|
|
@@ -1509,6 +1512,16 @@ export const nl: BuilderMessages = {
|
|
|
1509
1512
|
'idp.disabled_s': 'uitgeschakeld',
|
|
1510
1513
|
'idp.auto_s': 'automatisch aanmaken',
|
|
1511
1514
|
'idp.links_s': 'koppelt bestaande accounts',
|
|
1515
|
+
'idp.mfa_title': 'Meervoudige aanmelding',
|
|
1516
|
+
'idp.mfa_required': 'Meervoudige aanmelding bij deze provider vereisen',
|
|
1517
|
+
'idp.mfa_required_help': 'Aanmeldingen (bij applicaties en in de builder) weigeren die de provider niet als meervoudig aanmerkt: bij OpenID Connect via de amr- of acr-claim van het ID-token, bij SAML via de AuthnContextClassRef.',
|
|
1518
|
+
'idp.mfa_request': 'Vragen aan de provider',
|
|
1519
|
+
'idp.mfa_request_help': 'OpenID Connect-acr_values of SAML-AuthnContextClassRef-waarden, gescheiden door spaties. Leeg: niets vragen.',
|
|
1520
|
+
'idp.mfa_values': 'Waarden die meervoudig betekenen',
|
|
1521
|
+
'idp.mfa_values_help': 'amr-, acr- of AuthnContextClassRef-waarden, gescheiden door spaties; één ervan is genoeg. Entra ID en Okta zetten "mfa" in amr.',
|
|
1522
|
+
'idp.local_step': 'Na deze provider ook de eigen tweede stap van pgkiln vragen',
|
|
1523
|
+
'idp.local_step_help': 'Voor providers die geen meervoudige aanmelding kunnen afdwingen: de tweestapsregel van de applicatie geldt zoals na een wachtwoord, en ontwikkelaars met een tweede factor geven die ook. Opnieuw bevestigen vraagt dan de factor in pgkiln in plaats van opnieuw aanmelden bij de provider.',
|
|
1524
|
+
'idp.mfa_s': 'vereist MFA',
|
|
1512
1525
|
'idp.none': 'Nog geen identiteitsproviders.',
|
|
1513
1526
|
'idp.settings': 'Instellingen',
|
|
1514
1527
|
'idp.delete_confirm': '{name} verwijderen? Gekoppelde identiteiten worden verwijderd; de accounts blijven.',
|
package/src/builder/i18n/pt.ts
CHANGED
|
@@ -1295,6 +1295,9 @@ export const pt: BuilderMessages = {
|
|
|
1295
1295
|
'st.custom_post_help': 'PL/pgSQL opcional executado após uma verificação bem-sucedida, com p_username (e meta.app_user()), por exemplo para registrar o último login. Lançar uma exceção recusa o login. Processos de aplicação “after login” são executados depois, como de costume.',
|
|
1296
1296
|
'st.signin': 'Login',
|
|
1297
1297
|
'st.methods': 'Métodos de login',
|
|
1298
|
+
'st.signin_page': 'Página de acesso',
|
|
1299
|
+
'st.staying': 'Manter o acesso',
|
|
1300
|
+
'st.two_factor': 'Acesso em duas etapas',
|
|
1298
1301
|
'st.local': 'Nome de usuário e senha',
|
|
1299
1302
|
'st.ldap': 'Senhas do LDAP: {name}',
|
|
1300
1303
|
'st.disabled': '(desativado)',
|
|
@@ -1509,6 +1512,16 @@ export const pt: BuilderMessages = {
|
|
|
1509
1512
|
'idp.disabled_s': 'desativado',
|
|
1510
1513
|
'idp.auto_s': 'criação automática',
|
|
1511
1514
|
'idp.links_s': 'vincula contas existentes',
|
|
1515
|
+
'idp.mfa_title': 'Acesso multifator',
|
|
1516
|
+
'idp.mfa_required': 'Exigir acesso multifator neste provedor',
|
|
1517
|
+
'idp.mfa_required_help': 'Recusar acessos (às aplicações e ao builder) que o provedor não marca como multifator: no OpenID Connect pelo claim amr ou acr do token de ID, no SAML pelo AuthnContextClassRef.',
|
|
1518
|
+
'idp.mfa_request': 'Pedir ao provedor',
|
|
1519
|
+
'idp.mfa_request_help': 'Valores acr_values do OpenID Connect ou AuthnContextClassRef do SAML, separados por espaços. Vazio: não pedir nada.',
|
|
1520
|
+
'idp.mfa_values': 'Valores que significam multifator',
|
|
1521
|
+
'idp.mfa_values_help': 'Valores amr, acr ou AuthnContextClassRef, separados por espaços; basta um deles. Entra ID e Okta colocam "mfa" em amr.',
|
|
1522
|
+
'idp.local_step': 'Pedir a segunda etapa do próprio pgkiln após este provedor',
|
|
1523
|
+
'idp.local_step_help': 'Para provedores que não conseguem exigir multifator: a regra de duas etapas da aplicação vale como após uma senha, e desenvolvedores com um segundo fator também o informam. A nova confirmação pede então o fator do pgkiln em vez de entrar de novo no provedor.',
|
|
1524
|
+
'idp.mfa_s': 'exige MFA',
|
|
1512
1525
|
'idp.none': 'Ainda não há provedores de identidade.',
|
|
1513
1526
|
'idp.settings': 'Configurações',
|
|
1514
1527
|
'idp.delete_confirm': 'Excluir {name}? As identidades vinculadas são removidas; as contas ficam.',
|
|
@@ -23,7 +23,7 @@ interface AppRow {
|
|
|
23
23
|
mfa_email: boolean;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** The fields in the settings form. */
|
|
26
|
+
/** The fields in the settings form: the sign-in page, and two-factor sign-in (each placed by the settings page). */
|
|
27
27
|
export async function loginSettingsFields(a: AppRow) {
|
|
28
28
|
const pages = (await owner.query<{ page_no: number; name: string; requires_auth: boolean }>(
|
|
29
29
|
'select page_no, name, requires_auth from meta.page where app_id = $1 order by page_no', [a.id],
|
|
@@ -33,35 +33,33 @@ export async function loginSettingsFields(a: AppRow) {
|
|
|
33
33
|
from meta.app_mail_service ams join meta.mail_service s on s.id = ams.service_id where ams.app_id = $1`, [a.id],
|
|
34
34
|
);
|
|
35
35
|
const mailOk = !!(mail?.enabled && mail.from_address?.trim());
|
|
36
|
+
const noMail = html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address (Workspace utilities → Mail services).`;
|
|
36
37
|
const opt = (v: string, label: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${label}</option>`;
|
|
37
38
|
const current = a.login_page ? String(a.login_page) : '';
|
|
38
|
-
|
|
39
|
+
const page = html`<div class="form-grid">
|
|
39
40
|
<div class="field"><label class="label" for="f_login_page">Login page</label>
|
|
40
41
|
<select id="f_login_page" name="login_page">${opt('', '- the built-in sign-in page -', current)}${pages.map((p) =>
|
|
41
42
|
opt(String(p.page_no), `${p.page_no} ${p.name}${p.requires_auth ? ' (requires sign-in: not usable)' : ''}`, current))}</select>
|
|
42
|
-
<small class="help">A page
|
|
43
|
+
<small class="help">A public page with a <em>Login</em> region (APEX: the login page); page template <em>Minimal</em> hides the header and navigation.
|
|
43
44
|
<button class="link-button" formaction="${BASE}/apps/${a.id}/login-page">Create a login page</button></small></div>
|
|
44
|
-
<div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="password_reset" value="true"${a.password_reset ? raw(' checked') : ''}> Offer "Forgot password?"</label>
|
|
45
|
-
<small class="help">Users with a password and an e-mail address in the user directory get a link to choose a new password (single-use, valid for 30 minutes).
|
|
46
|
-
${mailOk
|
|
47
|
-
? html`Sent through mail service ${mail!.service} as ${mail!.from_address}.`
|
|
48
|
-
: html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address (Workspace utilities → Mail services).`}
|
|
49
|
-
Only for the user directory's own passwords: not for LDAP, single sign-on, database accounts or custom authentication.</small></div>
|
|
50
45
|
</div>
|
|
51
|
-
<
|
|
52
|
-
|
|
46
|
+
<div class="field"><label class="check"><input type="checkbox" name="password_reset" value="true"${a.password_reset ? raw(' checked') : ''}> Offer "Forgot password?"</label>
|
|
47
|
+
<small class="help">Users with a password and an e-mail address in the user directory get a single-use link, valid for 30 minutes, to choose a new password. Only for the user directory's own passwords.
|
|
48
|
+
${mailOk ? html`Sent through mail service ${mail!.service} as ${mail!.from_address}.` : noMail}</small></div>`;
|
|
49
|
+
const twoFactor = html`<div class="form-grid">
|
|
53
50
|
<div class="field"><label class="label" for="f_mfa">Second step</label>
|
|
54
51
|
<select id="f_mfa" name="mfa" aria-describedby="f_mfa_help">${(APP_MFA as string[]).map((m) => opt(m, MFA_LABELS[m as AppMfa], a.mfa))}</select>
|
|
55
|
-
<small class="help" id="f_mfa_help">After the password
|
|
52
|
+
<small class="help" id="f_mfa_help">After the password or an LDAP directory: a code from an authenticator app, a passkey or a recovery code, added on <em>My account</em> or while signing in.
|
|
56
53
|
${secretKeyConfigured() ? '' : html`<strong>Not available yet:</strong> the server needs <code>PGKILN_SECRET_KEY</code> to keep the apps' secrets.`}</small></div>
|
|
57
54
|
<div class="field"><label class="label" for="f_mfa_roles">Required for roles</label>
|
|
58
55
|
<input id="f_mfa_roles" name="mfa_roles" value="${(a.mfa_roles ?? []).join(', ')}" placeholder="admin, manager" aria-describedby="f_mfa_roles_help">
|
|
59
56
|
<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>
|
|
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
57
|
</div>
|
|
64
|
-
<
|
|
58
|
+
<div class="field"><label class="check"><input type="checkbox" name="mfa_email" value="true"${a.mfa_email ? raw(' checked') : ''}> Offer a code by e-mail</label>
|
|
59
|
+
<small class="help">Users who already have an app or a passkey may get a one-time code by e-mail instead (valid ${EMAIL_CODE_MINUTES} minutes, at most ${EMAIL_CODES_PER_HOUR} an hour); never as their only factor.
|
|
60
|
+
${mailOk ? '' : noMail}</small></div>
|
|
61
|
+
<p class="muted small">Passkeys need the application at a host name, not an IP address. Identity providers ask for their own second factor (see the provider's <em>Multi-factor sign-in</em>). To ask again before a page opens (step-up), give its authorization scheme <em>Second step within (minutes)</em> in Shared Components.</p>`;
|
|
62
|
+
return { page, twoFactor };
|
|
65
63
|
}
|
|
66
64
|
|
|
67
65
|
const MFA_LABELS: Record<AppMfa, string> = { off: 'Off', optional: 'Optional (users may add an app)', required: 'Required', roles: 'Required for some roles' };
|
package/src/builder/routes.ts
CHANGED
|
@@ -264,7 +264,16 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
264
264
|
const username = await resolveDeveloper(src, { ...result.identity, username: result.username, groups: result.groups });
|
|
265
265
|
const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [username]);
|
|
266
266
|
if (!dev) throw new SsoError('The developer was removed.');
|
|
267
|
-
|
|
267
|
+
// (sprint 132, SEC-4) the provider leaves the second step to pgkiln: as after a password (the trusted-browser
|
|
268
|
+
// cookie belongs to /builder and doesn't reach this callback, so the step is always asked); a developer without a
|
|
269
|
+
// factor signs in without __SSO, so a 2FA rule makes them add one (ui.ts)
|
|
270
|
+
if (p.local_second_step && (await hasFactor(dev.username))) {
|
|
271
|
+
const ns = await createSession(reply, null, BASE);
|
|
272
|
+
ns.state.__MFA = JSON.stringify({ u: dev.username, ws: null, w: '', next: safeNext(result.next), remember: false, weak: false, at: Date.now() });
|
|
273
|
+
await saveState(ns);
|
|
274
|
+
return reply.redirect(`${BASE}/login/2fa`, 303);
|
|
275
|
+
}
|
|
276
|
+
return finishSignIn(req, reply, null, dev, { ws: null, workspace: '', next: safeNext(result.next), remember: false, weak: false, picked: null, detail: `builder sso:${p.name}${result.mfa ? ' (mfa)' : ''}`, sso: p.local_second_step ? undefined : p.name });
|
|
268
277
|
});
|
|
269
278
|
|
|
270
279
|
// (sprint 128, SEC-2) signing in with a passkey alone: no user name, no password. The browser offers the
|
|
@@ -762,6 +771,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
762
771
|
if (!a) return reply.code(404).send('Not found');
|
|
763
772
|
const providers = (await owner.query('select name, display_name, enabled from meta.auth_provider order by display_name')).rows;
|
|
764
773
|
const directories = (await owner.query('select name, display_name, enabled from meta.ldap_directory order by display_name')).rows;
|
|
774
|
+
const signinFields = await loginSettingsFields(a);
|
|
765
775
|
const lists = (await owner.query('select name from meta.list where app_id = $1 order by name', [a.id])).rows.map((r) => r.name as string);
|
|
766
776
|
const listChoices = (v: string | null, none: string): [string, string][] => [['', none], ...lists.map((n): [string, string] => [n, n]), ...(v && !lists.includes(v) ? [[v, tx('st.missing', { name: v })] as [string, string]] : [])];
|
|
767
777
|
// (sprint 74) tabs like APEX's Edit Application Definition and Security Attributes: every tab of the
|
|
@@ -822,15 +832,21 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
822
832
|
<h3>${tx('st.methods')}</h3>
|
|
823
833
|
<div class="field"><label class="check"><input type="checkbox" name="local_login" value="true"${a.local_login ? raw(' checked') : ''}> ${tx('st.local')}</label></div>
|
|
824
834
|
${directories.length
|
|
825
|
-
? directories.map((d) => html`<div class="field"><label class="check"><input type="checkbox" name="ldap_directories" value="${d.name}"${a.ldap_directories.includes(d.name) ? raw(' checked') : ''}> ${tx('st.ldap', { name: d.display_name })}${d.enabled ? '' : ` ${tx('st.disabled')}`}</label></div>`)
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
<div class="form-grid">${input('session_group', tx('st.session_group'), a.session_group ?? '', { placeholder: tx('st.session_group_ph'), help: tx('st.session_group_help') })}</div>
|
|
829
|
-
${await loginSettingsFields(a)}
|
|
830
|
-
<div class="form-grid">${input('remember_me_days', tx('st.remember'), a.remember_me_days ?? '', { type: 'number', placeholder: tx('st.remember_ph'), help: tx('st.remember_help') })}</div>
|
|
835
|
+
? html`${directories.map((d) => html`<div class="field"><label class="check"><input type="checkbox" name="ldap_directories" value="${d.name}"${a.ldap_directories.includes(d.name) ? raw(' checked') : ''}> ${tx('st.ldap', { name: d.display_name })}${d.enabled ? '' : ` ${tx('st.disabled')}`}</label></div>`)}
|
|
836
|
+
<small class="help">${tx('st.ldap_help')}</small>`
|
|
837
|
+
: html`<p class="muted small">${tx.rich('st.no_ldap', { add: html`<a href="${BASE}/users/directories">${tx('st.add_one')}</a>` })}</p>`}
|
|
831
838
|
${providers.length
|
|
832
839
|
? providers.map((pr) => html`<div class="field"><label class="check"><input type="checkbox" name="sso_providers" value="${pr.name}"${a.sso_providers.includes(pr.name) ? raw(' checked') : ''}> ${tx('st.sso', { name: pr.display_name })}${pr.enabled ? '' : ` ${tx('st.disabled')}`}</label></div>`)
|
|
833
|
-
: html`<p class="muted">${tx.rich('st.no_sso', { add: html`<a href="${BASE}/users/providers">${tx('st.add_one')}</a>` })}</p>`}
|
|
840
|
+
: html`<p class="muted small">${tx.rich('st.no_sso', { add: html`<a href="${BASE}/users/providers">${tx('st.add_one')}</a>` })}</p>`}
|
|
841
|
+
<h3>${tx('st.signin_page')}</h3>
|
|
842
|
+
${signinFields.page}
|
|
843
|
+
<h3>${tx('st.staying')}</h3>
|
|
844
|
+
<div class="form-grid">
|
|
845
|
+
${input('remember_me_days', tx('st.remember'), a.remember_me_days ?? '', { type: 'number', placeholder: tx('st.remember_ph'), help: tx('st.remember_help') })}
|
|
846
|
+
${input('session_group', tx('st.session_group'), a.session_group ?? '', { placeholder: tx('st.session_group_ph'), help: tx('st.session_group_help') })}
|
|
847
|
+
</div>
|
|
848
|
+
<h3 id="two-factor">${tx('st.two_factor')}</h3>
|
|
849
|
+
${signinFields.twoFactor}
|
|
834
850
|
`)}${end}
|
|
835
851
|
${panel('ui')}${region(tx('st.tab.ui'), html`
|
|
836
852
|
<h3>${tx('st.theme')}</h3>
|
package/src/builder/users.ts
CHANGED
|
@@ -398,6 +398,9 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
398
398
|
});
|
|
399
399
|
|
|
400
400
|
// ---------------------------------------------------------------- identity providers (OpenID Connect)
|
|
401
|
+
/** (134) the values a new provider counts as a second factor: amr "mfa" (Entra ID, Okta), Entra ID's and SAML's own */
|
|
402
|
+
const DEFAULT_MFA_VALUES = ['mfa', 'http://schemas.microsoft.com/claims/multipleauthn',
|
|
403
|
+
'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract', 'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorUnregistered'];
|
|
401
404
|
const providerForm = (pr: any, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean, tx: BuilderTranslate) => html`
|
|
402
405
|
<form method="post" action="${action}">${csrfField}
|
|
403
406
|
<div class="form-grid">
|
|
@@ -419,6 +422,16 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
419
422
|
<small class="help">${tx('idp.auto_create_help')}</small></div>
|
|
420
423
|
<div class="field"><label class="check"><input type="checkbox" name="link_existing" value="true"${pr.link_existing ? raw(' checked') : ''}> ${tx('idp.link')}</label>
|
|
421
424
|
<small class="help">${tx('idp.link_help')}</small></div>
|
|
425
|
+
<fieldset class="prop-group u-mt075"><legend>${tx('idp.mfa_title')}</legend>
|
|
426
|
+
<div class="field"><label class="check"><input type="checkbox" name="mfa_required" value="true"${pr.mfa_required ? raw(' checked') : ''}> ${tx('idp.mfa_required')}</label>
|
|
427
|
+
<small class="help">${tx('idp.mfa_required_help')}</small></div>
|
|
428
|
+
<div class="form-grid">
|
|
429
|
+
${input('mfa_request', tx('idp.mfa_request'), pr.mfa_request ?? '', { help: tx('idp.mfa_request_help') })}
|
|
430
|
+
${input('mfa_values', tx('idp.mfa_values'), (pr.mfa_values ?? DEFAULT_MFA_VALUES).join(' '), { help: tx('idp.mfa_values_help') })}
|
|
431
|
+
</div>
|
|
432
|
+
<div class="field"><label class="check"><input type="checkbox" name="local_second_step" value="true"${pr.local_second_step ? raw(' checked') : ''}> ${tx('idp.local_step')}</label>
|
|
433
|
+
<small class="help">${tx('idp.local_step_help')}</small></div>
|
|
434
|
+
</fieldset>
|
|
422
435
|
<div class="field"><label class="check"><input type="checkbox" name="enabled" value="true"${pr.enabled !== false ? raw(' checked') : ''}> ${tx('idp.enabled')}</label></div>
|
|
423
436
|
${!isNew && pr.has_secret ? html`<div class="field"><label class="check"><input type="checkbox" name="remove_secret" value="true"> ${tx('idp.remove_secret')}</label></div>` : ''}
|
|
424
437
|
<div class="buttons"><button class="btn btn-hot">${isNew ? tx('idp.add') : tx('us.save')}</button></div>
|
|
@@ -439,13 +452,17 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
439
452
|
b.auto_create === 'true', b.enabled === 'true',
|
|
440
453
|
protocol === 'saml' ? b.idp_sso_url?.trim() || null : null, protocol === 'saml' ? pem(b.idp_cert) : null,
|
|
441
454
|
b.link_existing === 'true',
|
|
455
|
+
// (sprint 132, SEC-4) multi-factor sign-in at the provider
|
|
456
|
+
b.mfa_required === 'true', b.mfa_request?.trim().replace(/\s+/g, ' ').slice(0, 500) || null,
|
|
457
|
+
words(b.mfa_values).length ? words(b.mfa_values) : DEFAULT_MFA_VALUES, b.local_second_step === 'true',
|
|
442
458
|
];
|
|
459
|
+
const words = (v: string | undefined) => [...new Set((v ?? '').split(/\s+/).filter(Boolean))].slice(0, 20);
|
|
443
460
|
|
|
444
461
|
app.get(`${BASE}/users/providers`, async (req: Req, reply) => {
|
|
445
462
|
const s = await administrator(req, reply, 'Identity providers');
|
|
446
463
|
if (!s) return;
|
|
447
464
|
const tx = tr(s);
|
|
448
|
-
const rows = (await owner.query(`select id, name, display_name, issuer, enabled, auto_create, link_existing,
|
|
465
|
+
const rows = (await owner.query(`select id, name, display_name, issuer, enabled, auto_create, link_existing, mfa_required,
|
|
449
466
|
(select count(*) from meta.account_identity i where i.provider_id = p.id)::int as linked,
|
|
450
467
|
(select string_agg(a.alias, ', ' order by a.alias) from meta.app a where p.name = any(a.sso_providers)) as apps
|
|
451
468
|
from meta.auth_provider p order by display_name`)).rows;
|
|
@@ -460,7 +477,7 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
460
477
|
<td data-label="${tx('idp.issuer_col')}">${r.issuer}</td>
|
|
461
478
|
<td data-label="${tx('idp.used_by')}">${r.apps ?? html`<span class="muted">${tx('idp.no_apps')}</span>`}</td>
|
|
462
479
|
<td data-label="${tx('idp.linked')}">${r.linked}</td>
|
|
463
|
-
<td data-label="${tx('us.status')}">${r.enabled ? tx('idp.enabled_s') : html`<b>${tx('idp.disabled_s')}</b>`}${r.auto_create ? ` · ${tx('idp.auto_s')}` : ''}${r.link_existing ? ` · ${tx('idp.links_s')}` : ''}</td>
|
|
480
|
+
<td data-label="${tx('us.status')}">${r.enabled ? tx('idp.enabled_s') : html`<b>${tx('idp.disabled_s')}</b>`}${r.auto_create ? ` · ${tx('idp.auto_s')}` : ''}${r.link_existing ? ` · ${tx('idp.links_s')}` : ''}${r.mfa_required ? ` · ${tx('idp.mfa_s')}` : ''}</td>
|
|
464
481
|
</tr>`) : html`<tr><td colspan="5" class="empty">${tx('idp.none')}</td></tr>`}</tbody></table></div>`)}
|
|
465
482
|
${region(tx('idp.add'), providerForm({}, `${BASE}/users/providers`, csrf(s), true, tx))}
|
|
466
483
|
</div>`;
|
|
@@ -473,8 +490,9 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
473
490
|
const b = req.body ?? {};
|
|
474
491
|
try {
|
|
475
492
|
const r = await owner.one(
|
|
476
|
-
`insert into meta.auth_provider (name, display_name, issuer, client_id, scopes, username_claim, groups_claim, auto_create, enabled, idp_sso_url, idp_cert, link_existing,
|
|
477
|
-
|
|
493
|
+
`insert into meta.auth_provider (name, display_name, issuer, client_id, scopes, username_claim, groups_claim, auto_create, enabled, idp_sso_url, idp_cert, link_existing,
|
|
494
|
+
mfa_required, mfa_request, mfa_values, local_second_step, protocol, client_secret)
|
|
495
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) returning id`,
|
|
478
496
|
[b.name?.trim().toLowerCase(), ...providerValues(b, protocolOf(b.protocol), b.name?.trim().toLowerCase() ?? ''), protocolOf(b.protocol), b.client_secret || null],
|
|
479
497
|
);
|
|
480
498
|
flash(s, tr(s)('idp.added'));
|
|
@@ -523,7 +541,8 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
523
541
|
await owner.query(
|
|
524
542
|
`update meta.auth_provider set display_name = $2, issuer = $3, client_id = $4, scopes = $5, username_claim = $6,
|
|
525
543
|
groups_claim = $7, auto_create = $8, enabled = $9, idp_sso_url = $10, idp_cert = $11, link_existing = $12,
|
|
526
|
-
|
|
544
|
+
mfa_required = $13, mfa_request = $14, mfa_values = $15, local_second_step = $16,
|
|
545
|
+
client_secret = case when $18 then null when $17::text is null then client_secret else $17 end
|
|
527
546
|
where id = $1`,
|
|
528
547
|
[req.params.id, ...providerValues(b, current.protocol, current.name), b.client_secret || null, b.remove_secret === 'true'],
|
|
529
548
|
);
|
package/src/runtime/routes.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts
|
|
|
49
49
|
import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
|
|
50
50
|
import { passwordResetRoutes } from './password-reset.ts';
|
|
51
51
|
import { passwordSignIn, twoStepRoutes } from './two-step.ts';
|
|
52
|
-
import { carriesSecondStep } from '../account-mfa.ts';
|
|
52
|
+
import { carriesSecondStep, markSecondStep } from '../account-mfa.ts';
|
|
53
53
|
import { devbarOf, devbarRoutes, sessionDebugLevel } from './devbar.ts';
|
|
54
54
|
import { availabilityRefusal } from './availability.ts';
|
|
55
55
|
import { feedbackRoutes } from './feedback.ts';
|
|
@@ -141,6 +141,8 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
|
|
|
141
141
|
// (067) session sharing: the session belongs to the group's shared sign-in (a new one after a real sign-in)
|
|
142
142
|
const shared = opts.shared ?? (await issueShared(req, reply, a, username, { groups: opts.groups, method: opts.method }));
|
|
143
143
|
if (shared) s.state.__SHARED = shared;
|
|
144
|
+
// (sprint 132) how the session signed in: a step-up after "sso:<provider>" goes back to that provider
|
|
145
|
+
if (opts.method) s.state.__SIGNIN = opts.method;
|
|
144
146
|
await saveState(s);
|
|
145
147
|
|
|
146
148
|
// the application processes "after login", then (102) the application computations "after authentication"
|
|
@@ -162,6 +164,9 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
|
|
|
162
164
|
return s;
|
|
163
165
|
}
|
|
164
166
|
|
|
167
|
+
/** (sprint 132) Set by the runtime routes: a step-up through an identity provider (two-step.ts). */
|
|
168
|
+
export let ssoStepUp: (reply: FastifyReply, a: App, s: Session, p: Provider, next: string) => Promise<unknown>;
|
|
169
|
+
|
|
165
170
|
export async function completeLogin(req: FastifyRequest, reply: FastifyReply, a: App, oldSession: Session, username: string, opts: SignInOptions & { next?: string } = {}) {
|
|
166
171
|
await signIn(req, reply, a, oldSession, username, opts);
|
|
167
172
|
return reply.redirect(safeNext(a, opts.next), 303);
|
|
@@ -1345,6 +1350,13 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1345
1350
|
}
|
|
1346
1351
|
});
|
|
1347
1352
|
|
|
1353
|
+
/** (sprint 132) A step-up through the identity provider the session signed in with (two-step.ts). */
|
|
1354
|
+
ssoStepUp = async (reply: FastifyReply, a: App, s: Session, p: Provider, next: string) => {
|
|
1355
|
+
const { url, browserKey } = p.protocol === 'saml' ? await startSamlSignIn(p, a.id, next, undefined, s.id) : await startSignIn(p, a.id, next, undefined, s.id);
|
|
1356
|
+
reply.setCookie(SSO_COOKIE, browserKey, { path: '/sso', httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 600 });
|
|
1357
|
+
return reply.redirect(url, 303);
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1348
1360
|
app.get<{ Params: { provider: string } }>('/sso/callback/:provider', async (req, reply) => {
|
|
1349
1361
|
const ip = clientIp(req);
|
|
1350
1362
|
const p = await loadProvider(req.params.provider);
|
|
@@ -1375,9 +1387,24 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1375
1387
|
return simplePage(reply, 403, english('login.sso_failed'), e.message, result.link ? '/builder/security' : '/builder/login');
|
|
1376
1388
|
});
|
|
1377
1389
|
}
|
|
1390
|
+
if (result.stepup) return withAppDb(result.appId, () => finishSsoStepUp(req, reply, p, result, ip));
|
|
1378
1391
|
return withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
|
|
1379
1392
|
};
|
|
1380
|
-
|
|
1393
|
+
/** (sprint 132) A step-up confirmed by the provider: the session that asked for it gets its second step. */
|
|
1394
|
+
const finishSsoStepUp = async (req: FastifyRequest, reply: FastifyReply, p: Provider, result: SsoResult, ip: string) => {
|
|
1395
|
+
const s = await runtime.one<{ id: string; app_id: number; username: string | null; state: Record<string, unknown> }>(
|
|
1396
|
+
'select id, app_id, username, state from meta.session where id = $1', [result.stepup]);
|
|
1397
|
+
const a = s ? await loadApp((await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [s.app_id]))?.alias ?? '') : undefined;
|
|
1398
|
+
if (!s || !a || s.app_id !== result.appId || s.username?.toLowerCase() !== result.username.toLowerCase()) {
|
|
1399
|
+
logActivity({ appId: result.appId, username: result.username, event: 'login_failed', ip, detail: `sso:${p.name}: step-up for another session or user` });
|
|
1400
|
+
return simplePage(reply, 403, english('login.sso_failed'), english('login.sso_incomplete'));
|
|
1401
|
+
}
|
|
1402
|
+
markSecondStep(s.state);
|
|
1403
|
+
await runtime.query('update meta.session set state = $2 where id = $1', [s.id, s.state]);
|
|
1404
|
+
await logActivity({ appId: a.id, username: s.username, event: '2fa_stepup', ip, detail: `sso:${p.name}` });
|
|
1405
|
+
return reply.redirect(safeNext(a, result.next ?? undefined), 303);
|
|
1406
|
+
};
|
|
1407
|
+
const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: Provider, result: SsoResult, ip: string) => {
|
|
1381
1408
|
const alias = (await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [result.appId]))?.alias;
|
|
1382
1409
|
const loaded = alias ? await appWithLocale(req, alias) : undefined;
|
|
1383
1410
|
if (!loaded || loaded.app.authentication !== 'app_users' || !loaded.app.sso_providers.includes(p.name)) return simplePage(reply, 403, english('login.sso_failed'), english('login.method_unavailable'));
|
|
@@ -1388,7 +1415,16 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1388
1415
|
return simplePage(reply, 403, locale.t('login.no_access_title'), locale.t('login.no_access', { user: result.username, app: a.name }), `/a/${a.alias}/login`, locale);
|
|
1389
1416
|
}
|
|
1390
1417
|
const session = await getSession(req, reply, a.id, `/a/${a.alias}`);
|
|
1391
|
-
|
|
1418
|
+
const opts = { extraRoles: access.roles, next: result.next ?? undefined, method: `sso:${p.name}`, detail: `sso:${p.name}${result.mfa ? ' (mfa)' : ''}` };
|
|
1419
|
+
// (sprint 132) pgkiln's own second step after the provider, as after a password
|
|
1420
|
+
if (p.local_second_step) return passwordSignIn(req, reply, a, session, result.username, opts);
|
|
1421
|
+
const s = await signIn(req, reply, a, session, result.username, opts);
|
|
1422
|
+
// the provider's second factor counts for step-up (authorization schemes with mfa_minutes)
|
|
1423
|
+
if (result.mfa) {
|
|
1424
|
+
markSecondStep(s.state);
|
|
1425
|
+
await saveState(s);
|
|
1426
|
+
}
|
|
1427
|
+
return reply.redirect(safeNext(a, opts.next), 303);
|
|
1392
1428
|
};
|
|
1393
1429
|
|
|
1394
1430
|
// SAML: the IdP posts the response cross-site, where SameSite=Lax cookies aren't sent; post it on
|
package/src/runtime/two-step.ts
CHANGED
|
@@ -19,7 +19,8 @@ import { ALGORITHMS, giveChallenge, relyingParty, takeChallenge, verifyAssertion
|
|
|
19
19
|
import type { PageContext } from './context.ts';
|
|
20
20
|
import type { Locale } from './locale.ts';
|
|
21
21
|
import { pwaHead } from './pwa.ts';
|
|
22
|
-
import {
|
|
22
|
+
import { loadProvider, type Provider } from '../sso.ts';
|
|
23
|
+
import { appWithLocale, completeLogin, loadContext, rootAttrs, safeNext, signIn, ssoStepUp, type Req } from './routes.ts';
|
|
23
24
|
|
|
24
25
|
// (sprint 130, SEC-3) The second step of signing in to an application, and "Two-step sign-in" on My account.
|
|
25
26
|
//
|
|
@@ -34,6 +35,9 @@ import { appWithLocale, completeLogin, loadContext, rootAttrs, safeNext, signIn,
|
|
|
34
35
|
// and on My account, also as the first factor a user adds while signing in; a code by e-mail when the application
|
|
35
36
|
// allows it, for users who have another factor; step-up: a signed-in user whose page needs a more recent second step
|
|
36
37
|
// comes here with ?next= and goes back after it (the session's __MFA_AT); and My account → REST API token.
|
|
38
|
+
//
|
|
39
|
+
// (sprint 132, SEC-4) A session signed in through an identity provider steps up there instead: the provider signs the
|
|
40
|
+
// user in again with a second factor (sso.ts, saml.ts), unless the provider leaves the second step to pgkiln.
|
|
37
41
|
|
|
38
42
|
const PENDING = '__MFA';
|
|
39
43
|
const STEP_SECONDS = 300;
|
|
@@ -228,6 +232,15 @@ async function locked(reply: FastifyReply, a: App, locale: Locale, s: Session, p
|
|
|
228
232
|
return reply.code(429).type('text/html').send(card(a, locale, locale.t('mfa.step_title'), alert(locale.t('login.throttled', { minutes: loginWindowMinutes() }))));
|
|
229
233
|
}
|
|
230
234
|
|
|
235
|
+
/** (sprint 132) The identity provider a step-up goes back to: the one the session signed in with, if it decides itself. */
|
|
236
|
+
async function stepUpProvider(a: App, s: Session): Promise<Provider | null> {
|
|
237
|
+
const method = String(s.state.__SIGNIN ?? '');
|
|
238
|
+
if (!method.startsWith('sso:')) return null;
|
|
239
|
+
const name = method.slice(4).replace(/\+2fa$/, '');
|
|
240
|
+
const p = a.sso_providers.includes(name) ? await loadProvider(name) : undefined;
|
|
241
|
+
return p && !p.local_second_step ? p : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
231
244
|
export async function twoStepRoutes(app: FastifyInstance) {
|
|
232
245
|
app.get('/a/:alias/login/2fa', async (req: Req, reply) => {
|
|
233
246
|
const r = await start(req, reply);
|
|
@@ -237,6 +250,13 @@ export async function twoStepRoutes(app: FastifyInstance) {
|
|
|
237
250
|
// (sprint 131) step-up: a signed-in user sent here by a page that needs a more recent second step
|
|
238
251
|
const next = typeof (req.query as { next?: unknown }).next === 'string' ? String((req.query as { next: string }).next).slice(0, 2000) : undefined;
|
|
239
252
|
if (s.username && (!p || (next && p.next !== next))) {
|
|
253
|
+
const via = await stepUpProvider(a, s);
|
|
254
|
+
if (via)
|
|
255
|
+
return ssoStepUp(reply, a, s, via, next ?? `/a/${a.alias}/${a.home_page}`).catch((e) => {
|
|
256
|
+
req.log.warn({ err: e }, 'sso step-up failed');
|
|
257
|
+
return reply.code(502).type('text/html').send(card(a, locale, locale.t('mfa.stepup_title'),
|
|
258
|
+
alert(locale.t('login.sso_unavailable', { provider: via.display_name })), `/a/${a.alias}/${a.home_page}`));
|
|
259
|
+
});
|
|
240
260
|
const id = await accountId(s.username);
|
|
241
261
|
if (id === null || !(await accountHasFactor(id)))
|
|
242
262
|
return reply.code(403).type('text/html').send(card(a, locale, locale.t('mfa.stepup_title'),
|
package/src/saml.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { SAML, ValidateInResponseTo, type CacheProvider, type Profile } from '@node-saml/node-saml';
|
|
3
3
|
import { owner } from './db.ts';
|
|
4
|
-
import { publicUrl, resolveAccount, sha256, SsoError, usableUsername, type Provider, type SsoResult } from './sso.ts';
|
|
4
|
+
import { MFA_REFUSED, mfaRequest, provedMfa, publicUrl, resolveAccount, sha256, SsoError, stepUpResult, usableUsername, type Provider, type SsoResult } from './sso.ts';
|
|
5
5
|
|
|
6
6
|
// SAML 2.0 sign-in (APEX: SAML Sign-In), service-provider initiated:
|
|
7
7
|
//
|
|
@@ -18,6 +18,9 @@ import { publicUrl, resolveAccount, sha256, SsoError, usableUsername, type Provi
|
|
|
18
18
|
// answer an AuthnRequest of this flow, once. On top: the assertion's issuer
|
|
19
19
|
// (node-saml doesn't check it for responses), the one-time state and the browser
|
|
20
20
|
// that started the flow (as for OpenID Connect).
|
|
21
|
+
// (sprint 132, SEC-4) The assertion's AuthnContextClassRef tells how the user signed in: with mfa_required it must
|
|
22
|
+
// be one of the provider's mfa_values; mfa_request asks for those (RequestedAuthnContext); a step-up adds ForceAuthn
|
|
23
|
+
// and checks the AuthnInstant.
|
|
21
24
|
|
|
22
25
|
const PENDING_MINUTES = 10;
|
|
23
26
|
const b64url = (b: Buffer) => b.toString('base64url');
|
|
@@ -54,8 +57,9 @@ function requestCache(onSave: (id: string) => void, only?: string): CacheProvide
|
|
|
54
57
|
};
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
function client(p: Provider, cache: CacheProvider) {
|
|
60
|
+
function client(p: Provider, cache: CacheProvider, stepup = false) {
|
|
58
61
|
if (!p.idp_sso_url || !p.idp_cert) throw new SsoError('The SAML provider is not configured completely.');
|
|
62
|
+
const requested = mfaRequest(p);
|
|
59
63
|
return new SAML({
|
|
60
64
|
entryPoint: p.idp_sso_url,
|
|
61
65
|
issuer: spEntityId(p),
|
|
@@ -69,37 +73,51 @@ function client(p: Provider, cache: CacheProvider) {
|
|
|
69
73
|
requestIdExpirationPeriodMs: PENDING_MINUTES * 60_000,
|
|
70
74
|
cacheProvider: cache,
|
|
71
75
|
identifierFormat: null,
|
|
72
|
-
|
|
76
|
+
// (sprint 132) a RequestedAuthnContext only when the administrator names the classes
|
|
77
|
+
disableRequestedAuthnContext: !requested.length,
|
|
78
|
+
authnContext: requested,
|
|
79
|
+
racComparison: 'exact',
|
|
80
|
+
forceAuthn: stepup,
|
|
73
81
|
acceptedClockSkewMs: 60_000,
|
|
74
82
|
signatureAlgorithm: 'sha256',
|
|
75
83
|
});
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
/** Start: the IdP URL with the AuthnRequest, and the browser-binding cookie value. */
|
|
79
|
-
export async function startSamlSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
|
|
87
|
+
export async function startSamlSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string, stepup?: string) {
|
|
80
88
|
const state = b64url(randomBytes(32));
|
|
81
89
|
const browserKey = b64url(randomBytes(32));
|
|
82
90
|
await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
|
|
83
91
|
await owner.query(`delete from meta.saml_request where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
|
|
84
92
|
let requestId = '';
|
|
85
|
-
const url = await client(p, requestCache((id) => (requestId = id))).getAuthorizeUrlAsync(state, undefined, {});
|
|
93
|
+
const url = await client(p, requestCache((id) => (requestId = id)), !!stepup).getAuthorizeUrlAsync(state, undefined, {});
|
|
86
94
|
if (!requestId) throw new SsoError('The AuthnRequest has no ID.');
|
|
87
95
|
// code_verifier is unused for SAML; nonce holds the AuthnRequest ID the response must answer
|
|
88
96
|
await owner.query(
|
|
89
|
-
`insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username)
|
|
90
|
-
values ($1, $2, $3, '', $4, $5, $6, $7)`,
|
|
91
|
-
[state, appId, p.id, requestId, sha256(browserKey), next ?? null, link ?? null],
|
|
97
|
+
`insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username, stepup_session)
|
|
98
|
+
values ($1, $2, $3, '', $4, $5, $6, $7, $8)`,
|
|
99
|
+
[state, appId, p.id, requestId, sha256(browserKey), next ?? null, link ?? null, stepup ?? null],
|
|
92
100
|
);
|
|
93
101
|
return { url, browserKey };
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
/** (sprint 132) The signed assertion's AuthnContextClassRefs and AuthnInstant (ms), read after node-saml checked it. */
|
|
105
|
+
export function authnContext(assertionXml: string): { classes: string[]; at: number | null } {
|
|
106
|
+
// comments are outside the signature (exclusive c14n drops them): text split by one is read as one
|
|
107
|
+
assertionXml = assertionXml.replace(/<!--[\s\S]*?-->/g, '');
|
|
108
|
+
const classes = [...assertionXml.matchAll(/<(?:[\w-]+:)?AuthnContextClassRef\b[^>]*>\s*([^<\s]+)\s*</g)].map((m) => m[1]);
|
|
109
|
+
const instant = /<(?:[\w-]+:)?AuthnStatement\b[^>]*\bAuthnInstant="([^"]+)"/.exec(assertionXml)?.[1];
|
|
110
|
+
const at = instant ? Date.parse(instant) : NaN;
|
|
111
|
+
return { classes, at: Number.isFinite(at) ? at : null };
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
const values = (v: unknown): string[] => (v === undefined || v === null ? [] : (Array.isArray(v) ? v : [v]).map(String).filter(Boolean));
|
|
97
115
|
|
|
98
116
|
/** Finish: check state, browser and the response; the account and its groups. */
|
|
99
117
|
export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: string; RelayState?: string }, browserKey: string | undefined): Promise<SsoResult> {
|
|
100
118
|
const pending = await owner.one(
|
|
101
119
|
`delete from meta.sso_pending where state = $1 and provider_id = $2 and created_at > now() - make_interval(mins => $3)
|
|
102
|
-
returning app_id, nonce, browser_hash, next, link_username`,
|
|
120
|
+
returning app_id, nonce, browser_hash, next, link_username, stepup_session`,
|
|
103
121
|
[body.RelayState ?? '', p.id, PENDING_MINUTES],
|
|
104
122
|
);
|
|
105
123
|
if (!pending) throw new SsoError('This sign-in has expired or was already used. Please try again.');
|
|
@@ -115,6 +133,11 @@ export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: strin
|
|
|
115
133
|
// node-saml checks the issuer of logout messages only: the assertion must come from the configured IdP
|
|
116
134
|
if (profile?.issuer !== p.issuer) throw new SsoError(`The SAML response comes from ${profile?.issuer ?? 'an unknown issuer'}, not from ${p.issuer}.`);
|
|
117
135
|
if (!profile?.nameID) throw new SsoError('The SAML response names no user.');
|
|
136
|
+
// (sprint 132, SEC-4) how the user signed in
|
|
137
|
+
const how = authnContext(profile.getAssertionXml?.() ?? '');
|
|
138
|
+
const mfa = provedMfa(p, how.classes);
|
|
139
|
+
if (pending.stepup_session) return stepUpResult(p, pending, profile.nameID, mfa, how.at);
|
|
140
|
+
if (p.mfa_required && !mfa) throw new SsoError(MFA_REFUSED);
|
|
118
141
|
const username = p.username_claim === 'nameID' ? profile.nameID : values(profile[p.username_claim])[0];
|
|
119
142
|
if (!usableUsername(username)) throw new SsoError(`The SAML response has no usable "${p.username_claim}" attribute.`);
|
|
120
143
|
const name = values(profile.displayName ?? profile.cn)[0];
|
|
@@ -122,9 +145,9 @@ export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: strin
|
|
|
122
145
|
const groups = values(profile[p.groups_claim]);
|
|
123
146
|
if (pending.app_id === null)
|
|
124
147
|
// the builder: devsso.ts finds the developer (sprint 129)
|
|
125
|
-
return { appId: null, next: pending.next, username, groups, link: pending.link_username, identity: { subject: profile.nameID, name: name ?? null, email: email ?? null, mayLink: true } };
|
|
148
|
+
return { appId: null, next: pending.next, username, groups, link: pending.link_username, mfa, identity: { subject: profile.nameID, name: name ?? null, email: email ?? null, mayLink: true } };
|
|
126
149
|
const account = await resolveAccount(p, { sub: profile.nameID, name, email: profile.email ?? profile.mail }, username);
|
|
127
|
-
return { appId: pending.app_id, next: pending.next, username: account, groups };
|
|
150
|
+
return { appId: pending.app_id, next: pending.next, username: account, groups, mfa };
|
|
128
151
|
}
|
|
129
152
|
|
|
130
153
|
/** Service provider metadata (entity ID, ACS) to register pgkiln at the IdP. */
|
package/src/sso.ts
CHANGED
|
@@ -15,7 +15,9 @@ import { owner } from './db.ts';
|
|
|
15
15
|
// existing account is linked by username only when the provider allows
|
|
16
16
|
// it (link_existing), while it has no identity at that provider yet, and
|
|
17
17
|
// for the e-mail claim only with email_verified = true;
|
|
18
|
-
// * client secrets are read with the owner connection only
|
|
18
|
+
// * client secrets are read with the owner connection only;
|
|
19
|
+
// * (sprint 132, SEC-4) with mfa_required, a sign-in counts only when the ID token's amr or acr names a second
|
|
20
|
+
// factor (mfa_values); a step-up asks the provider to sign the user in again and checks auth_time too.
|
|
19
21
|
|
|
20
22
|
export interface Provider {
|
|
21
23
|
id: number;
|
|
@@ -40,6 +42,14 @@ export interface Provider {
|
|
|
40
42
|
builder_link_existing: boolean;
|
|
41
43
|
builder_group: string | null;
|
|
42
44
|
builder_admin_group: string | null;
|
|
45
|
+
/** (134, SEC-4) refuse sign-ins the provider does not mark as multi-factor */
|
|
46
|
+
mfa_required: boolean;
|
|
47
|
+
/** (134) acr_values / AuthnContextClassRef values to request, space separated */
|
|
48
|
+
mfa_request: string | null;
|
|
49
|
+
/** (134) amr / acr / AuthnContextClassRef values that count as a second factor */
|
|
50
|
+
mfa_values: string[];
|
|
51
|
+
/** (134) pgkiln asks its own second step after a sign-in through this provider */
|
|
52
|
+
local_second_step: boolean;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
interface Discovery {
|
|
@@ -54,6 +64,8 @@ export class SsoError extends Error {}
|
|
|
54
64
|
|
|
55
65
|
const ALGORITHMS = ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512', 'EdDSA'];
|
|
56
66
|
const PENDING_MINUTES = 10;
|
|
67
|
+
/** (sprint 132) A step-up through the provider: how old its new sign-in may be. */
|
|
68
|
+
export const STEPUP_AUTH_SECONDS = 600;
|
|
57
69
|
const b64url = (b: Buffer) => b.toString('base64url');
|
|
58
70
|
export const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
|
59
71
|
|
|
@@ -92,11 +104,23 @@ function jwks(doc: Discovery) {
|
|
|
92
104
|
return set;
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
/** (sprint 132) What the provider is asked for: its acr values (or AuthnContextClassRefs), split. */
|
|
108
|
+
export const mfaRequest = (p: Pick<Provider, 'mfa_request'>) => (p.mfa_request ?? '').split(/\s+/).filter(Boolean);
|
|
109
|
+
|
|
110
|
+
/** (sprint 132, SEC-4) Whether the provider says a second factor was used: one of its mfa_values among these. */
|
|
111
|
+
export function provedMfa(p: Pick<Provider, 'mfa_values'>, said: string[]): boolean {
|
|
112
|
+
const accepted = new Set(p.mfa_values ?? []);
|
|
113
|
+
return said.some((v) => accepted.has(v));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const MFA_REFUSED = 'The identity provider did not confirm a second factor (multi-factor sign-in), which this sign-in requires. Ask an administrator how to set one up at the identity provider.';
|
|
117
|
+
|
|
95
118
|
/**
|
|
96
119
|
* Start: returns the provider URL to redirect to, and the browser-binding cookie value.
|
|
97
|
-
* appId null: a sign-in to the builder; link: the signed-in developer linking this identity
|
|
120
|
+
* appId null: a sign-in to the builder; link: the signed-in developer linking this identity;
|
|
121
|
+
* stepup (sprint 132): the signed-in session a fresh multi-factor sign-in at the provider confirms.
|
|
98
122
|
*/
|
|
99
|
-
export async function startSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
|
|
123
|
+
export async function startSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string, stepup?: string) {
|
|
100
124
|
const doc = await discover(p.issuer);
|
|
101
125
|
const state = b64url(randomBytes(32));
|
|
102
126
|
const nonce = b64url(randomBytes(32));
|
|
@@ -104,12 +128,12 @@ export async function startSignIn(p: Provider, appId: number | null, next: strin
|
|
|
104
128
|
const browserKey = b64url(randomBytes(32));
|
|
105
129
|
await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
|
|
106
130
|
await owner.query(
|
|
107
|
-
`insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username)
|
|
108
|
-
values ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
109
|
-
[state, appId, p.id, verifier, nonce, sha256(browserKey), next ?? null, link ?? null],
|
|
131
|
+
`insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username, stepup_session)
|
|
132
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
133
|
+
[state, appId, p.id, verifier, nonce, sha256(browserKey), next ?? null, link ?? null, stepup ?? null],
|
|
110
134
|
);
|
|
111
135
|
const url = new URL(doc.authorization_endpoint);
|
|
112
|
-
|
|
136
|
+
const params = new URLSearchParams({
|
|
113
137
|
response_type: 'code',
|
|
114
138
|
client_id: p.client_id,
|
|
115
139
|
redirect_uri: redirectUri(p),
|
|
@@ -118,7 +142,15 @@ export async function startSignIn(p: Provider, appId: number | null, next: strin
|
|
|
118
142
|
nonce,
|
|
119
143
|
code_challenge: b64url(createHash('sha256').update(verifier).digest()),
|
|
120
144
|
code_challenge_method: 'S256',
|
|
121
|
-
})
|
|
145
|
+
});
|
|
146
|
+
// (sprint 132) what the provider should ask for; a step-up signs the user in again
|
|
147
|
+
const acr = mfaRequest(p);
|
|
148
|
+
if (acr.length) params.set('acr_values', acr.join(' '));
|
|
149
|
+
if (stepup) {
|
|
150
|
+
params.set('prompt', 'login');
|
|
151
|
+
params.set('max_age', '0');
|
|
152
|
+
}
|
|
153
|
+
url.search = params.toString();
|
|
122
154
|
return { url: url.toString(), browserKey };
|
|
123
155
|
}
|
|
124
156
|
|
|
@@ -145,6 +177,10 @@ export interface SsoResult {
|
|
|
145
177
|
identity?: { subject: string; name: string | null; email: string | null; mayLink: boolean };
|
|
146
178
|
/** builder: the signed-in developer who links this identity (Security page) */
|
|
147
179
|
link?: string | null;
|
|
180
|
+
/** (sprint 132) the provider said a second factor was used (amr / acr, AuthnContextClassRef) */
|
|
181
|
+
mfa?: boolean;
|
|
182
|
+
/** (sprint 132) a step-up: the signed-in session this sign-in confirms (username: that session's account) */
|
|
183
|
+
stepup?: string | null;
|
|
148
184
|
}
|
|
149
185
|
|
|
150
186
|
/** Callback: validate state/browser, exchange the code, verify the ID token, resolve the account. */
|
|
@@ -154,7 +190,7 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
|
|
|
154
190
|
const code = params.get('code') ?? '';
|
|
155
191
|
const pending = await owner.one(
|
|
156
192
|
`delete from meta.sso_pending where state = $1 and provider_id = $2 and created_at > now() - make_interval(mins => $3)
|
|
157
|
-
returning app_id, code_verifier, nonce, browser_hash, next, link_username`,
|
|
193
|
+
returning app_id, code_verifier, nonce, browser_hash, next, link_username, stepup_session`,
|
|
158
194
|
[state, p.id, PENDING_MINUTES],
|
|
159
195
|
);
|
|
160
196
|
if (!pending) throw new SsoError('This sign-in link has expired or was already used. Please try again.');
|
|
@@ -198,6 +234,12 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
|
|
|
198
234
|
const username = claim(payload, p.username_claim);
|
|
199
235
|
if (!usableUsername(username)) throw new SsoError(`The identity token has no usable "${p.username_claim}" claim.`);
|
|
200
236
|
if (p.username_claim === 'email' && payload.email_verified === false) throw new SsoError('Your e-mail address is not verified at the identity provider.');
|
|
237
|
+
// (sprint 132, SEC-4) how the user signed in: RFC 8176 amr values and the acr
|
|
238
|
+
const amr = Array.isArray(payload.amr) ? payload.amr.filter((v): v is string => typeof v === 'string') : [];
|
|
239
|
+
const mfa = provedMfa(p, typeof payload.acr === 'string' ? [...amr, payload.acr] : amr);
|
|
240
|
+
if (pending.stepup_session)
|
|
241
|
+
return stepUpResult(p, pending, payload.sub, mfa, typeof payload.auth_time === 'number' ? payload.auth_time * 1000 : null);
|
|
242
|
+
if (p.mfa_required && !mfa) throw new SsoError(MFA_REFUSED);
|
|
201
243
|
const rawGroups = claim(payload, p.groups_claim);
|
|
202
244
|
const groups = Array.isArray(rawGroups) ? rawGroups.filter((g): g is string => typeof g === 'string').map((g) => g.replace(/^\//, '')) : [];
|
|
203
245
|
|
|
@@ -207,12 +249,31 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
|
|
|
207
249
|
// the builder: devsso.ts finds the developer (sprint 129)
|
|
208
250
|
const email = payload.email_verified === false ? null : typeof payload.email === 'string' ? payload.email : null;
|
|
209
251
|
return {
|
|
210
|
-
appId: null, next: pending.next, username, groups, link: pending.link_username,
|
|
252
|
+
appId: null, next: pending.next, username, groups, link: pending.link_username, mfa,
|
|
211
253
|
identity: { subject: payload.sub, name: typeof payload.name === 'string' ? payload.name : null, email, mayLink: verified },
|
|
212
254
|
};
|
|
213
255
|
}
|
|
214
256
|
const account = await resolveAccount(p, payload, username, { mayLink: verified });
|
|
215
|
-
return { appId: pending.app_id, next: pending.next, username: account, groups };
|
|
257
|
+
return { appId: pending.app_id, next: pending.next, username: account, groups, mfa };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* (sprint 132) The end of a step-up through the provider (OpenID Connect or SAML): a new sign-in (at most
|
|
262
|
+
* STEPUP_AUTH_SECONDS old) with a second factor, by the identity already linked to the session's account.
|
|
263
|
+
* Nothing is created or linked here.
|
|
264
|
+
*/
|
|
265
|
+
export async function stepUpResult(
|
|
266
|
+
p: Pick<Provider, 'id' | 'mfa_values'>, pending: { app_id: number | null; next: string | null; stepup_session: string },
|
|
267
|
+
subject: string, mfa: boolean, authAt: number | null,
|
|
268
|
+
): Promise<SsoResult> {
|
|
269
|
+
if (!mfa) throw new SsoError(MFA_REFUSED);
|
|
270
|
+
if (authAt === null || Date.now() - authAt > STEPUP_AUTH_SECONDS * 1000 + 60_000)
|
|
271
|
+
throw new SsoError('The identity provider did not ask you to sign in again. Please try again.');
|
|
272
|
+
const acc = await owner.one<{ username: string }>(
|
|
273
|
+
`select a.username from meta.account_identity i join meta.account a on a.id = i.account_id
|
|
274
|
+
where i.provider_id = $1 and i.subject = $2 and a.active`, [p.id, subject]);
|
|
275
|
+
if (!acc) throw new SsoError('This identity is not linked to an account here.');
|
|
276
|
+
return { appId: pending.app_id, next: pending.next, username: acc.username, groups: [], mfa: true, stepup: pending.stepup_session };
|
|
216
277
|
}
|
|
217
278
|
|
|
218
279
|
/** A username from an identity provider: text without white space or colons, at most 100 characters. */
|