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.
@@ -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.
@@ -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. A directory checks a password, so pgkiln's second step follows as with the pgkiln password.
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*, shared or single sign-on session has no recent second step of pgkiln's own, so it is asked too.
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. Developers have
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
@@ -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; }
@@ -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.',
@@ -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.',
@@ -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.',
@@ -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.',
@@ -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.',
@@ -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
- return html`<div class="form-grid">
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 of the application with a <em>Login</em> region replaces the built-in sign-in page (APEX: the login page). It must not require authentication; with page template <em>Minimal</em> it has no header and navigation.
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
- <h3 id="two-factor">Two-factor sign-in</h3>
52
- <div class="form-grid">
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 (or an LDAP directory), users give a code from an authenticator app or a recovery code. They add the app on <em>My account</em>, or while signing in when it is required. An identity provider asks for its own second factor. Accounts share their apps across applications.
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
- <p class="muted small">Users also add <strong>passkeys</strong> (fingerprint, face, PIN or a security key) on <em>My account</em> or while signing in; they need the application at a host name, not an IP address. To ask for the second step again before a page opens (step-up), give its authorization scheme <em>Second step within (minutes)</em> in Shared Components.</p>`;
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' };
@@ -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
- return finishSignIn(req, reply, null, dev, { ws: null, workspace: '', next: safeNext(result.next), remember: false, weak: false, picked: null, detail: `builder sso:${p.name}`, sso: p.name });
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
- : html`<p class="muted">${tx.rich('st.no_ldap', { add: html`<a href="${BASE}/users/directories">${tx('st.add_one')}</a>` })}</p>`}
827
- <small class="help">${tx('st.ldap_help')}</small>
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>
@@ -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, protocol, client_secret)
477
- values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) returning id`,
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
- client_secret = case when $14 then null when $13::text is null then client_secret else $13 end
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
  );
@@ -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
- const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
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
- return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, method: `sso:${p.name}`, detail: `sso:${p.name}` });
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
@@ -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 { appWithLocale, completeLogin, loadContext, rootAttrs, safeNext, signIn, type Req } from './routes.ts';
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
- disableRequestedAuthnContext: true,
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
- url.search = new URLSearchParams({
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
- }).toString();
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. */