pgkiln 0.64.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
  2. package/docs/apex-feature-parity.md +5 -3
  3. package/docs/guide/08-security.md +41 -6
  4. package/docs/guide/12-development.md +10 -4
  5. package/docs/guide/13-rest-api.md +4 -0
  6. package/package.json +1 -1
  7. package/public/app.js +63 -0
  8. package/public/builder.js +1 -62
  9. package/src/account-mfa.ts +94 -3
  10. package/src/api.ts +9 -2
  11. package/src/builder/api.ts +3 -1
  12. package/src/builder/components.ts +1 -0
  13. package/src/builder/login-page.ts +10 -5
  14. package/src/builder/rest-modules.ts +1 -1
  15. package/src/builder/security.ts +3 -19
  16. package/src/builder/shared.ts +5 -3
  17. package/src/i18n/ar.ts +31 -0
  18. package/src/i18n/cs.ts +31 -0
  19. package/src/i18n/da.ts +31 -0
  20. package/src/i18n/de.ts +31 -0
  21. package/src/i18n/el.ts +31 -0
  22. package/src/i18n/es.ts +31 -0
  23. package/src/i18n/fi.ts +31 -0
  24. package/src/i18n/fr.ts +31 -0
  25. package/src/i18n/he.ts +31 -0
  26. package/src/i18n/it.ts +31 -0
  27. package/src/i18n/ja.ts +31 -0
  28. package/src/i18n/ko.ts +31 -0
  29. package/src/i18n/nb.ts +31 -0
  30. package/src/i18n/pl.ts +31 -0
  31. package/src/i18n/pt.ts +31 -0
  32. package/src/i18n/ru.ts +31 -0
  33. package/src/i18n/sv.ts +31 -0
  34. package/src/i18n/tr.ts +31 -0
  35. package/src/i18n/uk.ts +31 -0
  36. package/src/i18n/zh.ts +31 -0
  37. package/src/i18n.ts +62 -0
  38. package/src/metadata.ts +8 -2
  39. package/src/mfa.ts +18 -13
  40. package/src/runtime/account.ts +6 -3
  41. package/src/runtime/authz.ts +23 -4
  42. package/src/runtime/routes.ts +11 -1
  43. package/src/runtime/two-step.ts +379 -72
  44. package/src/webauthn.ts +20 -0
package/src/i18n/ru.ts CHANGED
@@ -839,4 +839,35 @@ export const ru: Record<MessageKey, string> = {
839
839
  "mfa.codes_left": "Осталось кодов восстановления: {n}",
840
840
  "mfa.codes_new": "Новые коды восстановления",
841
841
  "mfa.recovery_used": "Вы вошли с кодом восстановления. Осталось кодов восстановления: {n}.",
842
+ // (sprint 131) passkeys, e-mail codes, step-up, API tokens
843
+ "mfa.on_any": "При входе вы также вводите код или используете ключ доступа.",
844
+ "mfa.use_passkey": "Использовать ключ доступа",
845
+ "mfa.passkey_failed": "Ключ доступа не сработал. Попробуйте ещё раз или введите код.",
846
+ "mfa.email_send": "Лучше отправьте мне код по электронной почте",
847
+ "mfa.email_sent": "Мы отправили код на ваш адрес электронной почты. Он действует {minutes} мин.",
848
+ "mfa.email_failed": "Сейчас не удалось отправить код. Используйте приложение-аутентификатор, ключ доступа или резервный код.",
849
+ "mfa.email_subject": "Ваш код входа в {app}",
850
+ "mfa.email_body": "Ваш код входа в {app}: {code}. Он действует {minutes} мин.\n\nЕсли вы не пытались войти, смените пароль.",
851
+ "mfa.stepup_title": "Подтвердите, что это вы",
852
+ "mfa.stepup_intro": "Эта страница снова запрашивает второй шаг. Введите код из приложения-аутентификатора или резервный код либо используйте ключ доступа.",
853
+ "mfa.stepup_none": "Для этой страницы нужен вход в два этапа. Сначала добавьте приложение-аутентификатор или ключ доступа в разделе «Моя учётная запись».",
854
+ "mfa.my_account": "Моя учётная запись",
855
+ "mfa.passkey_default": "Ключ доступа",
856
+ "mfa.add_passkey": "Добавить ключ доступа",
857
+ "mfa.passkey_intro": "Ключ доступа позволяет входить по отпечатку пальца, лицу, блокировке экрана или ключу безопасности, не вводя код.",
858
+ "mfa.passkey_added": "Ключ доступа добавлен.",
859
+ "mfa.passkey_removed": "Ключ доступа удалён.",
860
+ "mfa.passkey_known": "Этот ключ доступа уже добавлен.",
861
+ "mfa.passkey_needs_host": "Для ключей доступа адрес приложения должен быть именем хоста, а не IP-адресом.",
862
+ "mfa.passkey_needs_js": "Для добавления ключа доступа нужен JavaScript.",
863
+ "mfa.or_passkey": "Или вместо этого добавьте ключ доступа:",
864
+ "mfa.kind_passkey": "Ключ доступа",
865
+ "mfa.kind_app": "Приложение-аутентификатор",
866
+ "token.title": "Токен REST API",
867
+ "token.intro": "Токен позволяет скрипту использовать REST API этого приложения от вашего имени, пока не истечёт срок. Он показывается только один раз.",
868
+ "token.needs_step": "Сначала вы снова проходите второй шаг.",
869
+ "token.hours": "Срок действия (часы)",
870
+ "token.create": "Создать токен",
871
+ "token.shown_once": "Ваш токен (показывается один раз, действует {hours} ч):",
872
+ "token.step_first": "Подтвердите, что это вы, прежде чем создавать токен.",
842
873
  };
package/src/i18n/sv.ts CHANGED
@@ -839,4 +839,35 @@ export const sv: Record<MessageKey, string> = {
839
839
  "mfa.codes_left": "Återstående återställningskoder: {n}",
840
840
  "mfa.codes_new": "Nya återställningskoder",
841
841
  "mfa.recovery_used": "Du loggade in med en återställningskod. Återstående återställningskoder: {n}.",
842
+ // (sprint 131) passkeys, e-mail codes, step-up, API tokens
843
+ "mfa.on_any": "När du loggar in anger du också en kod eller använder en nyckel (passkey).",
844
+ "mfa.use_passkey": "Använd en passkey",
845
+ "mfa.passkey_failed": "Passkey fungerade inte. Försök igen eller ange en kod.",
846
+ "mfa.email_send": "Skicka en kod via e-post i stället",
847
+ "mfa.email_sent": "Vi har skickat en kod till din e-postadress. Den gäller i {minutes} minuter.",
848
+ "mfa.email_failed": "Ingen kod kunde skickas just nu. Använd din autentiseringsapp, en passkey eller en återställningskod.",
849
+ "mfa.email_subject": "Din inloggningskod för {app}",
850
+ "mfa.email_body": "Din inloggningskod för {app} är {code}. Den gäller i {minutes} minuter.\n\nOm du inte försökte logga in, byt lösenord.",
851
+ "mfa.stepup_title": "Bekräfta att det är du",
852
+ "mfa.stepup_intro": "Den här sidan ber om ditt andra steg igen. Ange en kod från din autentiseringsapp eller en återställningskod, eller använd en passkey.",
853
+ "mfa.stepup_none": "Den här sidan kräver inloggning i två steg. Lägg först till en autentiseringsapp eller en passkey under Mitt konto.",
854
+ "mfa.my_account": "Mitt konto",
855
+ "mfa.passkey_default": "Passkey",
856
+ "mfa.add_passkey": "Lägg till en passkey",
857
+ "mfa.passkey_intro": "Med en passkey loggar du in med fingeravtryck, ansikte, skärmlås eller en säkerhetsnyckel, utan att skriva en kod.",
858
+ "mfa.passkey_added": "Passkey har lagts till.",
859
+ "mfa.passkey_removed": "Passkey har tagits bort.",
860
+ "mfa.passkey_known": "Den här passkey har redan lagts till.",
861
+ "mfa.passkey_needs_host": "Passkeys kräver att programmets adress är ett värdnamn, inte en IP-adress.",
862
+ "mfa.passkey_needs_js": "Att lägga till en passkey kräver JavaScript.",
863
+ "mfa.or_passkey": "Eller lägg till en passkey i stället:",
864
+ "mfa.kind_passkey": "Passkey",
865
+ "mfa.kind_app": "Autentiseringsapp",
866
+ "token.title": "REST API-token",
867
+ "token.intro": "Med en token kan ett skript använda programmets REST API som du, tills den går ut. Den visas bara en gång.",
868
+ "token.needs_step": "Du anger först ditt andra steg igen.",
869
+ "token.hours": "Giltig i (timmar)",
870
+ "token.create": "Skapa token",
871
+ "token.shown_once": "Din token (visas en gång, giltig i {hours} h):",
872
+ "token.step_first": "Bekräfta att det är du innan du skapar en token.",
842
873
  };
package/src/i18n/tr.ts CHANGED
@@ -839,4 +839,35 @@ export const tr: Record<MessageKey, string> = {
839
839
  "mfa.codes_left": "Kalan kurtarma kodları: {n}",
840
840
  "mfa.codes_new": "Yeni kurtarma kodları",
841
841
  "mfa.recovery_used": "Kurtarma koduyla oturum açtınız. Kalan kurtarma kodları: {n}.",
842
+ // (sprint 131) passkeys, e-mail codes, step-up, API tokens
843
+ "mfa.on_any": "Oturum açarken ayrıca bir kod girer veya bir geçiş anahtarı kullanırsınız.",
844
+ "mfa.use_passkey": "Geçiş anahtarı kullan",
845
+ "mfa.passkey_failed": "Geçiş anahtarı çalışmadı. Tekrar deneyin veya bir kod girin.",
846
+ "mfa.email_send": "Bunun yerine bana e-postayla kod gönder",
847
+ "mfa.email_sent": "E-posta adresinize bir kod gönderdik. {minutes} dakika geçerlidir.",
848
+ "mfa.email_failed": "Şu anda kod gönderilemedi. Kimlik doğrulama uygulamanızı, bir geçiş anahtarını veya bir kurtarma kodunu kullanın.",
849
+ "mfa.email_subject": "{app} için oturum açma kodunuz",
850
+ "mfa.email_body": "{app} için oturum açma kodunuz {code}. {minutes} dakika geçerlidir.\n\nOturum açmaya çalışmadıysanız parolanızı değiştirin.",
851
+ "mfa.stepup_title": "Siz olduğunuzu doğrulayın",
852
+ "mfa.stepup_intro": "Bu sayfa ikinci adımınızı yeniden istiyor. Kimlik doğrulama uygulamanızdan bir kod veya bir kurtarma kodu girin ya da bir geçiş anahtarı kullanın.",
853
+ "mfa.stepup_none": "Bu sayfa iki adımlı oturum açma gerektirir. Önce Hesabım bölümünde bir kimlik doğrulama uygulaması veya geçiş anahtarı ekleyin.",
854
+ "mfa.my_account": "Hesabım",
855
+ "mfa.passkey_default": "Geçiş anahtarı",
856
+ "mfa.add_passkey": "Geçiş anahtarı ekle",
857
+ "mfa.passkey_intro": "Geçiş anahtarıyla kod yazmadan parmak iziniz, yüzünüz, ekran kilidiniz veya bir güvenlik anahtarıyla oturum açarsınız.",
858
+ "mfa.passkey_added": "Geçiş anahtarı eklendi.",
859
+ "mfa.passkey_removed": "Geçiş anahtarı kaldırıldı.",
860
+ "mfa.passkey_known": "Bu geçiş anahtarı zaten eklenmiş.",
861
+ "mfa.passkey_needs_host": "Geçiş anahtarları, uygulamanın adresinin IP adresi değil bir ana bilgisayar adı olmasını gerektirir.",
862
+ "mfa.passkey_needs_js": "Geçiş anahtarı eklemek için JavaScript gerekir.",
863
+ "mfa.or_passkey": "Ya da bunun yerine bir geçiş anahtarı ekleyin:",
864
+ "mfa.kind_passkey": "Geçiş anahtarı",
865
+ "mfa.kind_app": "Kimlik doğrulama uygulaması",
866
+ "token.title": "REST API belirteci",
867
+ "token.intro": "Belirteç, bir betiğin süresi dolana kadar bu uygulamanın REST API'sini sizin adınıza kullanmasını sağlar. Yalnızca bir kez gösterilir.",
868
+ "token.needs_step": "Önce ikinci adımınızı yeniden verirsiniz.",
869
+ "token.hours": "Geçerlilik (saat)",
870
+ "token.create": "Belirteç oluştur",
871
+ "token.shown_once": "Belirteciniz (bir kez gösterilir, {hours} sa geçerli):",
872
+ "token.step_first": "Belirteç oluşturmadan önce siz olduğunuzu doğrulayın.",
842
873
  };
package/src/i18n/uk.ts CHANGED
@@ -839,4 +839,35 @@ export const uk: Record<MessageKey, string> = {
839
839
  "mfa.codes_left": "Залишилося кодів відновлення: {n}",
840
840
  "mfa.codes_new": "Нові коди відновлення",
841
841
  "mfa.recovery_used": "Ви увійшли з кодом відновлення. Залишилося кодів відновлення: {n}.",
842
+ // (sprint 131) passkeys, e-mail codes, step-up, API tokens
843
+ "mfa.on_any": "Під час входу ви також вводите код або використовуєте ключ доступу.",
844
+ "mfa.use_passkey": "Використати ключ доступу",
845
+ "mfa.passkey_failed": "Ключ доступу не спрацював. Спробуйте ще раз або введіть код.",
846
+ "mfa.email_send": "Краще надішліть мені код електронною поштою",
847
+ "mfa.email_sent": "Ми надіслали код на вашу адресу електронної пошти. Він дійсний {minutes} хв.",
848
+ "mfa.email_failed": "Зараз не вдалося надіслати код. Використайте застосунок-автентифікатор, ключ доступу або резервний код.",
849
+ "mfa.email_subject": "Ваш код входу до {app}",
850
+ "mfa.email_body": "Ваш код входу до {app}: {code}. Він дійсний {minutes} хв.\n\nЯкщо ви не намагалися увійти, змініть пароль.",
851
+ "mfa.stepup_title": "Підтвердьте, що це ви",
852
+ "mfa.stepup_intro": "Ця сторінка знову запитує другий крок. Введіть код із застосунку-автентифікатора або резервний код чи використайте ключ доступу.",
853
+ "mfa.stepup_none": "Для цієї сторінки потрібен вхід у два кроки. Спершу додайте застосунок-автентифікатор або ключ доступу в розділі «Мій обліковий запис».",
854
+ "mfa.my_account": "Мій обліковий запис",
855
+ "mfa.passkey_default": "Ключ доступу",
856
+ "mfa.add_passkey": "Додати ключ доступу",
857
+ "mfa.passkey_intro": "Ключ доступу дає змогу входити за відбитком пальця, обличчям, блокуванням екрана або ключем безпеки, не вводячи код.",
858
+ "mfa.passkey_added": "Ключ доступу додано.",
859
+ "mfa.passkey_removed": "Ключ доступу видалено.",
860
+ "mfa.passkey_known": "Цей ключ доступу вже додано.",
861
+ "mfa.passkey_needs_host": "Для ключів доступу адреса застосунку має бути іменем хоста, а не IP-адресою.",
862
+ "mfa.passkey_needs_js": "Щоб додати ключ доступу, потрібен JavaScript.",
863
+ "mfa.or_passkey": "Або натомість додайте ключ доступу:",
864
+ "mfa.kind_passkey": "Ключ доступу",
865
+ "mfa.kind_app": "Застосунок-автентифікатор",
866
+ "token.title": "Токен REST API",
867
+ "token.intro": "Токен дає змогу скрипту використовувати REST API цього застосунку від вашого імені, доки не мине строк. Він показується лише один раз.",
868
+ "token.needs_step": "Спершу ви знову проходите другий крок.",
869
+ "token.hours": "Дійсний (години)",
870
+ "token.create": "Створити токен",
871
+ "token.shown_once": "Ваш токен (показується один раз, дійсний {hours} год):",
872
+ "token.step_first": "Підтвердьте, що це ви, перш ніж створювати токен.",
842
873
  };
package/src/i18n/zh.ts CHANGED
@@ -839,4 +839,35 @@ export const zh: Record<MessageKey, string> = {
839
839
  "mfa.codes_left": "剩余恢复代码:{n}",
840
840
  "mfa.codes_new": "新的恢复代码",
841
841
  "mfa.recovery_used": "你使用恢复代码登录了。剩余恢复代码:{n}。",
842
+ // (sprint 131) passkeys, e-mail codes, step-up, API tokens
843
+ "mfa.on_any": "登录时,您还需要输入代码或使用通行密钥。",
844
+ "mfa.use_passkey": "使用通行密钥",
845
+ "mfa.passkey_failed": "通行密钥无效。请重试或输入代码。",
846
+ "mfa.email_send": "改为通过电子邮件发送代码",
847
+ "mfa.email_sent": "我们已向您的电子邮件地址发送了代码,有效期为 {minutes} 分钟。",
848
+ "mfa.email_failed": "目前无法发送代码。请使用身份验证器应用、通行密钥或恢复代码。",
849
+ "mfa.email_subject": "您的 {app} 登录代码",
850
+ "mfa.email_body": "您的 {app} 登录代码是 {code},有效期为 {minutes} 分钟。\n\n如果您没有尝试登录,请更改密码。",
851
+ "mfa.stepup_title": "确认是您本人",
852
+ "mfa.stepup_intro": "此页面再次要求您完成第二步。请输入身份验证器应用中的代码或恢复代码,或使用通行密钥。",
853
+ "mfa.stepup_none": "此页面需要两步登录。请先在“我的帐户”中添加身份验证器应用或通行密钥。",
854
+ "mfa.my_account": "我的帐户",
855
+ "mfa.passkey_default": "通行密钥",
856
+ "mfa.add_passkey": "添加通行密钥",
857
+ "mfa.passkey_intro": "使用通行密钥,您可以通过指纹、面容、屏幕锁或安全密钥登录,无需输入代码。",
858
+ "mfa.passkey_added": "已添加通行密钥。",
859
+ "mfa.passkey_removed": "已删除通行密钥。",
860
+ "mfa.passkey_known": "此通行密钥已添加过。",
861
+ "mfa.passkey_needs_host": "通行密钥要求应用程序的地址是主机名,而不是 IP 地址。",
862
+ "mfa.passkey_needs_js": "添加通行密钥需要 JavaScript。",
863
+ "mfa.or_passkey": "或者改为添加通行密钥:",
864
+ "mfa.kind_passkey": "通行密钥",
865
+ "mfa.kind_app": "身份验证器应用",
866
+ "token.title": "REST API 令牌",
867
+ "token.intro": "令牌允许脚本以您的身份使用此应用程序的 REST API,直到过期。它只显示一次。",
868
+ "token.needs_step": "您需要先再次完成第二步。",
869
+ "token.hours": "有效期(小时)",
870
+ "token.create": "创建令牌",
871
+ "token.shown_once": "您的令牌(仅显示一次,有效期 {hours} 小时):",
872
+ "token.step_first": "创建令牌前,请确认是您本人。",
842
873
  };
package/src/i18n.ts CHANGED
@@ -866,6 +866,37 @@ const en = {
866
866
  "mfa.codes_left": "Recovery codes left: {n}",
867
867
  "mfa.codes_new": "New recovery codes",
868
868
  "mfa.recovery_used": "You signed in with a recovery code. Recovery codes left: {n}.",
869
+ // (sprint 131) passkeys, e-mail codes, step-up, API tokens
870
+ "mfa.on_any": "When you sign in, you also give a code or use a passkey.",
871
+ "mfa.use_passkey": "Use a passkey",
872
+ "mfa.passkey_failed": "The passkey did not work. Try again, or enter a code.",
873
+ "mfa.email_send": "E-mail me a code instead",
874
+ "mfa.email_sent": "We sent a code to your e-mail address. It is valid for {minutes} minutes.",
875
+ "mfa.email_failed": "No code could be sent right now. Use your authenticator app, a passkey or a recovery code.",
876
+ "mfa.email_subject": "Your sign-in code for {app}",
877
+ "mfa.email_body": "Your sign-in code for {app} is {code}. It is valid for {minutes} minutes.\n\nIf you did not try to sign in, change your password.",
878
+ "mfa.stepup_title": "Confirm it's you",
879
+ "mfa.stepup_intro": "This page asks for your second step again. Enter a code from your authenticator app or a recovery code, or use a passkey.",
880
+ "mfa.stepup_none": "This page needs two-factor sign-in. Add an authenticator app or a passkey on My account first.",
881
+ "mfa.my_account": "My account",
882
+ "mfa.passkey_default": "Passkey",
883
+ "mfa.add_passkey": "Add a passkey",
884
+ "mfa.passkey_intro": "A passkey signs you in with your fingerprint, face, screen lock or a security key, without typing a code.",
885
+ "mfa.passkey_added": "The passkey was added.",
886
+ "mfa.passkey_removed": "The passkey was removed.",
887
+ "mfa.passkey_known": "This passkey was added already.",
888
+ "mfa.passkey_needs_host": "Passkeys need the application's address to be a host name, not an IP address.",
889
+ "mfa.passkey_needs_js": "Adding a passkey needs JavaScript.",
890
+ "mfa.or_passkey": "Or add a passkey instead:",
891
+ "mfa.kind_passkey": "Passkey",
892
+ "mfa.kind_app": "Authenticator app",
893
+ "token.title": "REST API token",
894
+ "token.intro": "A token lets a script use this application's REST API as you, until it expires. It is shown only once.",
895
+ "token.needs_step": "You give your second step again first.",
896
+ "token.hours": "Valid for (hours)",
897
+ "token.create": "Create token",
898
+ "token.shown_once": "Your token (shown once, valid {hours} h):",
899
+ "token.step_first": "Confirm it's you before you create a token.",
869
900
  };
870
901
 
871
902
  export type MessageKey = keyof typeof en;
@@ -1701,6 +1732,37 @@ const nl: Record<MessageKey, string> = {
1701
1732
  "mfa.codes_left": "Resterende herstelcodes: {n}",
1702
1733
  "mfa.codes_new": "Nieuwe herstelcodes",
1703
1734
  "mfa.recovery_used": "Je hebt je aangemeld met een herstelcode. Resterende herstelcodes: {n}.",
1735
+ // (sprint 131) passkeys, e-mail codes, step-up, API tokens
1736
+ "mfa.on_any": "Bij het aanmelden geef je ook een code of gebruik je een passkey.",
1737
+ "mfa.use_passkey": "Passkey gebruiken",
1738
+ "mfa.passkey_failed": "De passkey werkte niet. Probeer het opnieuw of voer een code in.",
1739
+ "mfa.email_send": "Stuur me liever een code per e-mail",
1740
+ "mfa.email_sent": "We hebben een code naar je e-mailadres gestuurd. Die is {minutes} minuten geldig.",
1741
+ "mfa.email_failed": "Er kon nu geen code worden verstuurd. Gebruik je authenticator-app, een passkey of een herstelcode.",
1742
+ "mfa.email_subject": "Je aanmeldcode voor {app}",
1743
+ "mfa.email_body": "Je aanmeldcode voor {app} is {code}. Die is {minutes} minuten geldig.\n\nProbeerde je niet aan te melden? Wijzig dan je wachtwoord.",
1744
+ "mfa.stepup_title": "Bevestig dat jij het bent",
1745
+ "mfa.stepup_intro": "Deze pagina vraagt opnieuw om je tweede stap. Voer een code uit je authenticator-app of een herstelcode in, of gebruik een passkey.",
1746
+ "mfa.stepup_none": "Voor deze pagina is aanmelden in twee stappen nodig. Voeg eerst een authenticator-app of een passkey toe bij Mijn account.",
1747
+ "mfa.my_account": "Mijn account",
1748
+ "mfa.passkey_default": "Passkey",
1749
+ "mfa.add_passkey": "Passkey toevoegen",
1750
+ "mfa.passkey_intro": "Met een passkey meld je je aan met je vingerafdruk, gezicht, schermvergrendeling of een beveiligingssleutel, zonder een code te typen.",
1751
+ "mfa.passkey_added": "De passkey is toegevoegd.",
1752
+ "mfa.passkey_removed": "De passkey is verwijderd.",
1753
+ "mfa.passkey_known": "Deze passkey was al toegevoegd.",
1754
+ "mfa.passkey_needs_host": "Passkeys werken alleen als het adres van de applicatie een hostnaam is, geen IP-adres.",
1755
+ "mfa.passkey_needs_js": "Voor het toevoegen van een passkey is JavaScript nodig.",
1756
+ "mfa.or_passkey": "Of voeg in plaats daarvan een passkey toe:",
1757
+ "mfa.kind_passkey": "Passkey",
1758
+ "mfa.kind_app": "Authenticator-app",
1759
+ "token.title": "REST API-token",
1760
+ "token.intro": "Met een token gebruikt een script de REST API van deze applicatie als jou, tot het verloopt. Het wordt maar één keer getoond.",
1761
+ "token.needs_step": "Je geeft eerst opnieuw je tweede stap.",
1762
+ "token.hours": "Geldig (uren)",
1763
+ "token.create": "Token maken",
1764
+ "token.shown_once": "Je token (één keer getoond, {hours} uur geldig):",
1765
+ "token.step_first": "Bevestig dat jij het bent voordat je een token maakt.",
1704
1766
  };
1705
1767
 
1706
1768
  const BUILTIN: Record<string, Record<string, string>> = { en, nl, de, fr, es, it, pt, pl, sv, da, nb, cs, ja, zh, fi, tr, el, ru, uk, ko, ar, he };
package/src/metadata.ts CHANGED
@@ -6,6 +6,8 @@ export interface AuthzScheme {
6
6
  type: 'role' | 'sql';
7
7
  value: string;
8
8
  error_message: string;
9
+ /** (133) step-up: passes only when the session's last second step is at most this many minutes old */
10
+ mfa_minutes?: number | null;
9
11
  }
10
12
 
11
13
  export interface NavEntry {
@@ -81,6 +83,10 @@ export interface App {
81
83
  /** (132) two-step sign-in for the application's users (src/account-mfa.ts) */
82
84
  mfa: 'off' | 'optional' | 'required' | 'roles';
83
85
  mfa_roles: string[];
86
+ /** (133) a code by e-mail at the second step, for users with another factor */
87
+ mfa_email: boolean;
88
+ /** (133) users create their own REST API token on My account */
89
+ api_self_tokens: boolean;
84
90
  /** LDAP directories the password form checks after local accounts */
85
91
  ldap_directories: string[];
86
92
  /** Progressive Web App: installable, offline pages, offline form queue */
@@ -372,7 +378,7 @@ const agg = (table: string, fk: string, parent: string, appId: string) =>
372
378
  // No caching on purpose: edits made in the builder show up on the next request.
373
379
  export async function loadApp(alias: string) {
374
380
  return runtime.one<App>(
375
- `select a.id, a.alias, a.name, a.version, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.mfa, a.mfa_roles, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
381
+ `select a.id, a.alias, a.name, a.version, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.mfa, a.mfa_roles, a.mfa_email, a.api_self_tokens, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
376
382
  a.availability, a.availability_message, a.availability_users, a.feedback, a.error_function, a.session_warning_seconds, a.component_settings,
377
383
  a.language, a.languages, a.language_from, a.date_format, a.timestamp_format, a.time_zone, a.time_zone_auto, a.currency,
378
384
  coalesce((select jsonb_agg(jsonb_build_object('name', l.name, 'query', l.query, 'rest_source', l.rest_source)) from meta.lov l where l.app_id = a.id), '[]') as lovs,
@@ -381,7 +387,7 @@ export async function loadApp(alias: string) {
381
387
  'dialog_position', p.dialog_position, 'dialog_size', p.dialog_size))
382
388
  from meta.page p where p.app_id = a.id and meta.build_option_on(a.id, p.build_option)), '[]') as pages,
383
389
  ${agg('meta.nav_entry', 'app_id', 'a', 'a.id')} as nav,
384
- coalesce((select jsonb_agg(jsonb_build_object('name', s.name, 'type', s.type, 'value', s.value, 'error_message', s.error_message))
390
+ coalesce((select jsonb_agg(jsonb_build_object('name', s.name, 'type', s.type, 'value', s.value, 'error_message', s.error_message, 'mfa_minutes', s.mfa_minutes))
385
391
  from meta.authz_scheme s where s.app_id = a.id), '[]') as authz_schemes,
386
392
  coalesce((select jsonb_agg(i.name) from meta.app_item i where i.app_id = a.id), '[]') as app_items,
387
393
  ${agg('meta.app_process', 'app_id', 'a', 'a.id')} as app_processes,
package/src/mfa.ts CHANGED
@@ -185,22 +185,32 @@ export const addTotp = (username: string, name: string, secret: string, step: nu
185
185
  export interface Passkey { id: number; name: string; credential_id: string; public_key: Record<string, string>; alg: number; sign_count: string; transports: string[] }
186
186
 
187
187
  /** Add a passkey (after src/webauthn.ts checked the browser's answer). */
188
- export async function addPasskey(username: string, name: string, p: { credentialId: string; jwk: Record<string, string>; alg: number; signCount: number; rpId: string; transports: string[] }) {
188
+ export async function storeAddPasskey(st: FactorStore, who: Who, name: string, p: { credentialId: string; jwk: Record<string, string>; alg: number; signCount: number; rpId: string; transports: string[] }) {
189
189
  await owner.query(
190
- `insert into meta.developer_factor (username, kind, name, credential_id, public_key, alg, sign_count, rp_id, transports, last_used_at)
190
+ `insert into ${st.factors} (${st.key}, kind, name, credential_id, public_key, alg, sign_count, rp_id, transports, last_used_at)
191
191
  values ($1, 'passkey', $2, $3, $4, $5, $6, $7, $8, now())`,
192
- [username, name.trim().slice(0, 60) || 'Passkey', p.credentialId, JSON.stringify(p.jwk), p.alg, p.signCount, p.rpId,
192
+ [who, name.trim().slice(0, 60) || 'Passkey', p.credentialId, JSON.stringify(p.jwk), p.alg, p.signCount, p.rpId,
193
193
  p.transports.filter((t) => /^[a-z-]{1,20}$/.test(t)).slice(0, 8)],
194
194
  );
195
195
  }
196
196
 
197
- /** The developer's passkeys for this relying party (a passkey only works on the domain it was made for). */
198
- export async function passkeysOf(username: string, rpId: string): Promise<Passkey[]> {
197
+ /** The passkeys for this relying party (a passkey only works on the domain it was made for). */
198
+ export async function storePasskeys(st: FactorStore, who: Who, rpId: string): Promise<Passkey[]> {
199
199
  return (await owner.query<Passkey>(
200
- `select id, name, credential_id, public_key, alg, sign_count, transports from meta.developer_factor
201
- where username = $1 and kind = 'passkey' and rp_id = $2 order by id`, [username, rpId])).rows;
200
+ `select id, name, credential_id, public_key, alg, sign_count, transports from ${st.factors}
201
+ where ${st.key} = $1 and kind = 'passkey' and rp_id = $2 order by id`, [who, rpId])).rows;
202
202
  }
203
203
 
204
+ /** Record a sign-in with a passkey; false when another sign-in moved the counter first (a replay or a clone). */
205
+ export async function storeUsePasskey(st: FactorStore, id: number, oldCount: string, newCount: number): Promise<boolean> {
206
+ return !!(await owner.query(
207
+ `update ${st.factors} set sign_count = $3, last_used_at = now() where id = $1 and sign_count = $2::bigint`,
208
+ [id, oldCount, newCount])).rowCount;
209
+ }
210
+
211
+ export const addPasskey = (username: string, name: string, p: Parameters<typeof storeAddPasskey>[3]) => storeAddPasskey(DEVELOPERS, username, name, p);
212
+ export const passkeysOf = (username: string, rpId: string) => storePasskeys(DEVELOPERS, username, rpId);
213
+
204
214
  /** (sprint 128) The passkey with this credential id for this relying party, and whose it is (sign-in without a user name). */
205
215
  export async function passkeyByCredential(credentialId: string, rpId: string): Promise<(Passkey & { username: string }) | null> {
206
216
  if (!credentialId || credentialId.length > 1400) return null;
@@ -221,12 +231,7 @@ export async function setPasskeySignin(on: boolean) {
221
231
  await owner.query(`insert into meta.setting (name, value) values ('mfa_passkey_signin', $1) on conflict (name) do update set value = excluded.value`, [on ? 'on' : 'off']);
222
232
  }
223
233
 
224
- /** Record a sign-in with a passkey; false when another sign-in moved the counter first (a replay or a clone). */
225
- export async function usePasskey(id: number, oldCount: string, newCount: number): Promise<boolean> {
226
- return !!(await owner.query(
227
- 'update meta.developer_factor set sign_count = $3, last_used_at = now() where id = $1 and sign_count = $2::bigint',
228
- [id, oldCount, newCount])).rowCount;
229
- }
234
+ export const usePasskey = (id: number, oldCount: string, newCount: number) => storeUsePasskey(DEVELOPERS, id, oldCount, newCount);
230
235
 
231
236
  // ---------------------------------------------------------------- trusted browsers (sprint 127)
232
237
 
@@ -12,13 +12,15 @@ import { chrome, clientTexts } from './render.ts';
12
12
  import { appStyles, choosable, chosenStyleName, styleChoice } from './styles.ts';
13
13
  import { loadApp } from '../metadata.ts';
14
14
  import { pushSection } from './push.ts';
15
- import { twoStepSection } from './two-step.ts';
15
+ import { relyingParty } from '../webauthn.ts';
16
+ import { tokenSection, twoStepSection } from './two-step.ts';
16
17
  import { appWithLocale, loadContext, safeNext, txContext, type Req } from './routes.ts';
17
18
 
18
19
  // "My account": details, own password, and preferences (light/dark and
19
20
  // language). APEX has the APIs for this (APEX_UTIL.CHANGE_CURRENT_USER_PW,
20
21
  // theme style per user); pgkiln provides the page itself. (sprint 130)
21
- // Two-step sign-in: authenticator apps and recovery codes (two-step.ts).
22
+ // Two-step sign-in: authenticator apps and recovery codes (two-step.ts). (sprint 131) Passkeys, and a REST API
23
+ // token the user creates (when the application allows it).
22
24
 
23
25
  /** Keep a signed-in user's style variant for this app (meta.account_style; '' = the base colours). */
24
26
  async function saveStyle(appId: number, username: string, style: string) {
@@ -117,7 +119,8 @@ async function accountPage(ctx: PageContext, reply: FastifyReply, error?: string
117
119
  <form method="post" action="${ctx.base}/account/devices">${csrf}<button class="btn">${t('account.forget_devices')}</button></form>
118
120
  </div></section>`
119
121
  : ''}
120
- ${await twoStepSection(ctx)}
122
+ ${await twoStepSection(ctx, relyingParty(reply.request).rpId)}
123
+ ${await tokenSection(ctx)}
121
124
  ${await pushSection(ctx)}
122
125
  </div>
123
126
  </div>
@@ -1,10 +1,14 @@
1
+ import { secondStepWithin } from '../account-mfa.ts';
1
2
  import { applyBinds } from '../binds.ts';
3
+ import type { AuthzScheme } from '../metadata.ts';
2
4
  import { savepoint } from '../db.ts';
3
5
  import { bindValues, publicError, stripSemicolon, type PageContext, type Visibility } from './context.ts';
4
6
  import { saveButton, saveRequest } from './grid.ts';
5
7
  import { selectionOf } from './report.ts';
6
8
 
7
9
  export class Forbidden extends Error {}
10
+ /** (sprint 131) The page needs a second step more recent than the session's: ask for one, then come back. */
11
+ export class StepUp extends Forbidden {}
8
12
 
9
13
  /**
10
14
  * Evaluate an authorization reference ("ADMIN", "!ADMIN", or the built-in
@@ -20,15 +24,28 @@ export async function isAuthorized(ctx: PageContext, ref: string | null | undefi
20
24
  if (name === 'MUST_NOT_BE_PUBLIC_USER') ok = ctx.user !== 'nobody';
21
25
  else {
22
26
  const scheme = ctx.app.authz_schemes.find((s) => s.name === name);
23
- if (!scheme) ok = false;
24
- else if (scheme.type === 'role') ok = ctx.roles.includes(scheme.value.toLowerCase());
25
- else ok = await sqlTrue(ctx, scheme.value, `authorization ${name}`);
27
+ ok = !!scheme && (await schemePasses(ctx, scheme)) && (!scheme.mfa_minutes || secondStepWithin(ctx.session?.state ?? {}, scheme.mfa_minutes));
26
28
  }
27
29
  ctx.authzCache.set(name, ok);
28
30
  }
29
31
  return negate ? !ok : ok;
30
32
  }
31
33
 
34
+ async function schemePasses(ctx: PageContext, scheme: AuthzScheme): Promise<boolean> {
35
+ if (scheme.type === 'role') return ctx.roles.includes(scheme.value.toLowerCase());
36
+ return sqlTrue(ctx, scheme.value, `authorization ${scheme.name}`);
37
+ }
38
+
39
+ /**
40
+ * (sprint 131) Whether a reference fails only for its step-up: a scheme with mfa_minutes that the user passes, in a
41
+ * session whose last second step is too old (or never happened).
42
+ */
43
+ export async function onlyStepUpMissing(ctx: PageContext, ref: string | null | undefined): Promise<boolean> {
44
+ if (!ref || ref.startsWith('!') || ctx.user === 'nobody') return false;
45
+ const scheme = ctx.app.authz_schemes.find((s) => s.name === ref.trim().toUpperCase());
46
+ return !!scheme?.mfa_minutes && !secondStepWithin(ctx.session?.state ?? {}, scheme.mfa_minutes) && (await schemePasses(ctx, scheme));
47
+ }
48
+
32
49
  /**
33
50
  * Evaluate a SQL boolean expression with binds. Errors yield `onError`
34
51
  * (false by default: a broken condition hides a region or button).
@@ -51,7 +68,9 @@ export function authzMessage(ctx: PageContext, ref: string | null) {
51
68
  }
52
69
 
53
70
  export async function checkPageAccess(ctx: PageContext) {
54
- if (!(await isAuthorized(ctx, ctx.page.authz))) throw new Forbidden(authzMessage(ctx, ctx.page.authz));
71
+ if (await isAuthorized(ctx, ctx.page.authz)) return;
72
+ if (await onlyStepUpMissing(ctx, ctx.page.authz)) throw new StepUp(authzMessage(ctx, ctx.page.authz));
73
+ throw new Forbidden(authzMessage(ctx, ctx.page.authz));
55
74
  }
56
75
 
57
76
  /** Can the current user open this page? Used to hide links and menu entries. */
@@ -18,7 +18,7 @@ import { english, type Translate } from '../i18n.ts';
18
18
  import { checksumValid, loginWindowMinutes, urlChecksum } from '../security.ts';
19
19
  import { builderSsoFinish, enabledProviders, finishSignIn, loadProvider, ssoAccess, SsoError, startSignIn, type Provider, type SsoResult } from '../sso.ts';
20
20
  import { sessionLimits, clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash, type Session } from '../session.ts';
21
- import { checkPageAccess, computeVisibility, Forbidden, isAuthorized } from './authz.ts';
21
+ import { checkPageAccess, computeVisibility, Forbidden, isAuthorized, StepUp } from './authz.ts';
22
22
  import { bindValues, dbg, publicError, stripSemicolon, timed, toState, writeOut, type PageContext } from './context.ts';
23
23
  import { startDebug } from '../debug.ts';
24
24
  import { assignable, clearPageItems, fetchForms, processConditionHolds, ProcessFailed, runAppProcesses, runProcesses, runSql, validate, ValidationFailed } from './engine.ts';
@@ -316,6 +316,13 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
316
316
  };
317
317
  }
318
318
 
319
+ /** (sprint 131) Step-up: the page's authorization needs a recent second step; ask for it, then come back. */
320
+ async function stepUp(ctx: PageContext, reply: FastifyReply, next: string) {
321
+ dbg(ctx, 2, 'access', `step-up: a second step is needed for page ${ctx.page.page_no}`);
322
+ await saveState(ctx.session);
323
+ return reply.redirect(`${ctx.base}/login/2fa?next=${encodeURIComponent(next)}`, 303);
324
+ }
325
+
319
326
  async function forbidden(ctx: PageContext, reply: FastifyReply, message: string, detail: string) {
320
327
  dbg(ctx, 2, 'access', `refused (403): ${detail}`);
321
328
  logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'forbidden', ip: ctx.ip, detail });
@@ -487,6 +494,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
487
494
  return { html: await timed(ctx, 4, 'page', 'render page', () => renderPage(ctx)) };
488
495
  });
489
496
  } catch (e) {
497
+ if (e instanceof StepUp) return stepUp(ctx, reply, req.url);
490
498
  if (e instanceof Forbidden) return forbidden(ctx, reply, e.message, `page ${ctx.page.page_no}`);
491
499
  throw e;
492
500
  }
@@ -583,6 +591,8 @@ export async function runtimeRoutes(app: FastifyInstance) {
583
591
  return pressed;
584
592
  });
585
593
  } catch (e) {
594
+ // the second step expired while the page was open: ask again, then back to the page (the input is lost)
595
+ if (e instanceof StepUp) return stepUp(ctx, reply, `${ctx.base}/${ctx.page.page_no}`);
586
596
  if (e instanceof Forbidden) return forbidden(ctx, reply, e.message, `forged or unavailable request "${body.__request}" on page ${ctx.page.page_no}`);
587
597
  if (e instanceof ValidationFailed) {
588
598
  ctx.errors = e.errors;