fix(login): correctly redirect to /password when ignoreUnknownUsername is set (#11130)

Closes #11006 

# Which Problems Are Solved

This PR addresses an issue where the ignoreUnknownUsernames setting was
not being respected in certain scenarios during the login flow.
Specifically:

- When a user was found but rejected due to login settings (e.g.,
disableLoginWithEmail), the system would return a "User not found" error
instead of redirecting to the password page as dictated by
ignoreUnknownUsernames.
- When a user was not found, and the flow fell through the registration
checks (e.g., IDP redirect failed), it would also return "User not
found" without checking ignoreUnknownUsernames.

# How the Problems Are Solved

- Introduced a helper function `handleUserNotFound` in 
`apps/login/src/lib/server/loginname.ts` to centralize the logic for
checking ignoreUnknownUsernames and redirecting to the password page.
- Updated the user validation logic to call handleUserNotFound when a
user is found but rejected by policy.
- Updated the registration fallback logic to call handleUserNotFound
instead of directly returning an error.
- Added new test cases in `apps/login/src/lib/server/loginname.test.ts`
to verify the fix and ensure no regressions.

# Additional Changes

- Fixed inconsistency in translation files

---------

Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Max Peintner
2026-01-14 08:24:48 +00:00
committed by GitHub
co-authored by Livio Spring
parent e72203ca6d
commit 26f8238499
51 changed files with 5066 additions and 3111 deletions
+6 -6
View File
@@ -147,7 +147,9 @@
"couldNotLoadSession": "Sitzung konnte nicht geladen werden",
"couldNotLoadAuthMethods": "Authentifizierungsmethoden konnten nicht geladen werden",
"failedPrecondition": "Vorbedingung fehlgeschlagen",
"sessionNotValid": "Sitzung ist nicht gültig"
"sessionNotValid": "Sitzung ist nicht gültig",
"passwordVerificationMissing": "Passwort-Verifizierung fehlt.",
"passwordVerificationTooOld": "Passwort-Verifizierung ist zu alt. Bitte verifizieren Sie Ihr Passwort erneut."
}
},
"idp": {
@@ -274,10 +276,7 @@
"couldNotFindSession": "Sitzung konnte nicht gefunden werden",
"couldNotUpdateSession": "Sitzung konnte nicht aktualisiert werden",
"userNotFound": "Benutzer nicht im System gefunden",
"couldNotDetermineRedirect": "Weiterleitungs-URL nach Passkey-Verifizierung konnte nicht ermittelt werden",
"couldNotSetSession": "Sitzung konnte nicht gesetzt werden",
"couldNotGetUser": "Benutzerdaten konnten nicht abgerufen werden",
"couldNotRetrieveSessionCookie": "Sitzungs-Cookie konnte nicht abgerufen werden"
"couldNotDetermineRedirect": "Weiterleitungs-URL nach Passkey-Verifizierung konnte nicht ermittelt werden"
}
},
"set": {
@@ -409,7 +408,8 @@
"userAlreadyVerified": "Benutzer ist bereits verifiziert!",
"couldNotResendInvite": "Einladung konnte nicht erneut gesendet werden",
"inviteSendFailed": "Einladungs-E-Mail konnte nicht gesendet werden",
"emailSendFailed": "Verifizierungs-E-Mail konnte nicht gesendet werden"
"emailSendFailed": "Verifizierungs-E-Mail konnte nicht gesendet werden",
"contextMissing": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."
}
},
"authenticator": {
+5 -2
View File
@@ -147,7 +147,9 @@
"couldNotLoadSession": "Could not load session",
"couldNotLoadAuthMethods": "Could not load auth methods",
"failedPrecondition": "Failed precondition",
"sessionNotValid": "Session is not valid"
"sessionNotValid": "Session is not valid",
"passwordVerificationMissing": "Password verification is missing.",
"passwordVerificationTooOld": "Password verification is too old. Please verify your password again."
}
},
"idp": {
@@ -406,7 +408,8 @@
"userAlreadyVerified": "User is already verified!",
"couldNotResendInvite": "Could not resend invite",
"inviteSendFailed": "Failed to send invitation email",
"emailSendFailed": "Failed to send verification email"
"emailSendFailed": "Failed to send verification email",
"contextMissing": "An error occurred. Please try again."
}
},
"authenticator": {
+6 -6
View File
@@ -147,7 +147,9 @@
"couldNotLoadSession": "No se pudo cargar la sesión",
"couldNotLoadAuthMethods": "No se pudieron cargar los métodos de autenticación",
"failedPrecondition": "Precondición fallida",
"sessionNotValid": "La sesión no es válida"
"sessionNotValid": "La sesión no es válida",
"passwordVerificationMissing": "Falta la verificación de la contraseña.",
"passwordVerificationTooOld": "La verificación de la contraseña es demasiado antigua. Por favor, verifique su contraseña de nuevo."
}
},
"idp": {
@@ -274,10 +276,7 @@
"couldNotFindSession": "No se pudo encontrar la sesión",
"couldNotUpdateSession": "No se pudo actualizar la sesión",
"userNotFound": "Usuario no encontrado en el sistema",
"couldNotDetermineRedirect": "No se pudo determinar la URL de redirección después de la verificación de clave de acceso",
"couldNotSetSession": "No se pudo establecer la sesión",
"couldNotGetUser": "No se pudieron recuperar los datos del usuario",
"couldNotRetrieveSessionCookie": "No se pudo recuperar la cookie de sesión"
"couldNotDetermineRedirect": "No se pudo determinar la URL de redirección después de la verificación de clave de acceso"
}
},
"set": {
@@ -409,7 +408,8 @@
"userAlreadyVerified": "¡El usuario ya está verificado!",
"couldNotResendInvite": "No se pudo reenviar la invitación",
"inviteSendFailed": "No se pudo enviar el correo de invitación",
"emailSendFailed": "No se pudo enviar el correo de verificación"
"emailSendFailed": "No se pudo enviar el correo electrónico de verificación",
"contextMissing": "Ocurrió un error. Por favor inténtelo de nuevo."
}
},
"authenticator": {
+6 -6
View File
@@ -147,7 +147,9 @@
"couldNotLoadSession": "Impossibile caricare la sessione",
"couldNotLoadAuthMethods": "Impossibile caricare i metodi di autenticazione",
"failedPrecondition": "Precondizione non soddisfatta",
"sessionNotValid": "La sessione non è valida"
"sessionNotValid": "La sessione non è valida",
"passwordVerificationMissing": "Manca la verifica della password.",
"passwordVerificationTooOld": "La verifica della password è troppo vecchia. Si prega di verificare nuovamente la password."
}
},
"idp": {
@@ -274,10 +276,7 @@
"couldNotFindSession": "Impossibile trovare la sessione",
"couldNotUpdateSession": "Impossibile aggiornare la sessione",
"userNotFound": "Utente non trovato nel sistema",
"couldNotDetermineRedirect": "Impossibile determinare l'URL di reindirizzamento dopo la verifica passkey",
"couldNotSetSession": "Impossibile impostare la sessione",
"couldNotGetUser": "Impossibile recuperare i dati dell'utente",
"couldNotRetrieveSessionCookie": "Impossibile recuperare il cookie di sessione"
"couldNotDetermineRedirect": "Impossibile determinare l'URL di reindirizzamento dopo la verifica passkey"
}
},
"set": {
@@ -409,7 +408,8 @@
"userAlreadyVerified": "L'utente è già verificato!",
"couldNotResendInvite": "Impossibile reinviare l'invito",
"inviteSendFailed": "Impossibile inviare l'email di invito",
"emailSendFailed": "Impossibile inviare l'email di verifica"
"emailSendFailed": "Impossibile inviare l'email di verifica",
"contextMissing": "Si è verificato un errore. Riprova."
}
},
"authenticator": {
+95 -3
View File
@@ -35,6 +35,22 @@
},
"required": {
"loginName": "必須項目です"
},
"errors": {
"internalError": "内部エラーが発生しました",
"couldNotGetLoginSettings": "ログイン設定を取得できませんでした",
"couldNotSearchUsers": "ユーザーを検索できませんでした",
"couldNotGetDomain": "ドメインを取得できませんでした",
"couldNotGetHost": "ホストを取得できませんでした",
"couldNotStartIDPFlow": "IDPフローを開始できませんでした",
"moreThanOneUserFound": "複数のユーザーが見つかりました。一意の識別子を指定してください。",
"userNotFound": "ユーザーがシステムに見つかりません",
"couldNotCreateSession": "ユーザーのセッションを作成できませんでした",
"initialUserNotSupported": "初期ユーザーはサポートされていません",
"usernamePasswordNotAllowed": "ユーザー名とパスワードは許可されていません!詳細については管理者にお問い合わせください。",
"passkeysNotAllowed": "パスキーは許可されていません!詳細については管理者にお問い合わせください。",
"couldNotFindIdentityProvider": "IDプロバイダーが見つかりませんでした。",
"userNotActive": "ユーザーはアクティブではありません。詳細については管理者にお問い合わせください。"
}
},
"password": {
@@ -48,6 +64,13 @@
},
"required": {
"password": "必須項目です"
},
"errors": {
"couldNotVerifyPassword": "パスワードを確認できませんでした",
"couldNotResetPassword": "パスワードをリセットできませんでした"
},
"info": {
"passwordResetSent": "パスワードがリセットされました。メールを確認してください"
}
},
"set": {
@@ -66,6 +89,11 @@
"code": "必須項目です",
"newPassword": "パスワードを入力してください!",
"confirmPassword": "必須項目です"
},
"errors": {
"couldNotSetPassword": "パスワードを設定できませんでした",
"couldNotResetPassword": "パスワードをリセットできませんでした",
"couldNotVerifyPassword": "パスワードを確認できませんでした"
}
},
"change": {
@@ -79,7 +107,42 @@
"required": {
"newPassword": "新しいパスワードを入力してください!",
"confirmPassword": "必須項目です"
},
"errors": {
"couldNotChangePassword": "パスワードを変更できませんでした",
"couldNotVerifyPassword": "パスワードを確認できませんでした",
"unknownError": "不明なエラー"
}
},
"errors": {
"passwordVerificationMissing": "パスワード確認がありません。",
"passwordVerificationTooOld": "パスワード確認が古すぎます。もう一度パスワードを確認してください。",
"noHostFound": "ホストが見つかりません",
"couldNotSendResetLink": "パスワードリセットリンクを送信できませんでした",
"couldNotCreateSessionForUser": "ユーザーのセッションを作成できませんでした",
"couldNotVerifyPassword": "パスワードを確認できませんでした",
"failedToAuthenticate": "認証に失敗しました。パスワード試行回数 {maxPasswordAttempts} 回中 {failedAttempts} 回失敗しました。{lockoutMessage}",
"failedToAuthenticateNoLimit": "認証に失敗しました。",
"accountLockedContactAdmin": " アカウントのロックを解除するには管理者に連絡してください",
"userNotFound": "ユーザーがシステムに見つかりません",
"initialUserNotSupported": "初期ユーザーはサポートされていません",
"userInitialStateNotSupported": "ユーザーの初期状態はサポートされていません",
"codeOrVerificationRequired": "コードを提供するか、有効なユーザー確認チェックを行う必要があります",
"verificationRequired": "ユーザー確認チェックを行う必要があります",
"couldNotLoadSession": "セッションを読み込めませんでした",
"couldNotLoadAuthMethods": "認証方法を読み込めませんでした",
"failedPrecondition": "前提条件に失敗しました",
"sessionNotValid": "セッションが無効です"
},
"complexity": {
"length": "{minLength} 文字以上である必要があります。",
"hasSymbol": "記号を含める必要があります。",
"hasNumber": "数字を含める必要があります。",
"hasUppercase": "大文字を含める必要があります。",
"hasLowercase": "小文字を含める必要があります。",
"equals": "パスワード確認が一致しました。",
"matches": "一致します",
"doesNotMatch": "一致しません"
}
},
"idp": {
@@ -194,7 +257,20 @@
"title": "パスキーで認証",
"description": "デバイスが指紋、顔、または画面ロックの入力を求めます",
"usePassword": "パスワードを使用",
"submit": "続行"
"submit": "続行",
"errors": {
"couldNotRequestChallenge": "パスキーチャレンジを要求できませんでした",
"couldNotVerifyPasskey": "パスキーを確認できませんでした",
"noResponseReceived": "パスキー確認に失敗しました - 応答がありません",
"noRedirectProvided": "パスキー確認は完了しましたが、リダイレクトが提供されませんでした",
"couldNotRetrievePasskey": "パスキーの取得中にエラーが発生しました",
"verificationCancelled": "パスキー確認がキャンセルされました",
"verificationFailed": "パスキー確認中にエラーが発生しました",
"couldNotFindSession": "セッションが見つかりませんでした",
"couldNotUpdateSession": "セッションを更新できませんでした",
"userNotFound": "ユーザーがシステムに見つかりません",
"couldNotDetermineRedirect": "パスキー確認後のリダイレクトURLを決定できませんでした"
}
},
"set": {
"title": "パスキーの設定",
@@ -261,6 +337,13 @@
"firstname": "必須項目です",
"lastname": "必須項目です",
"email": "必須項目です"
},
"errors": {
"couldNotCreateUser": "ユーザーを作成できませんでした",
"couldNotCreateSession": "セッションを作成できませんでした",
"userNotFound": "ユーザーがシステムに見つかりません",
"couldNotLinkIDP": "IDPをユーザーにリンクできませんでした",
"couldNotRegisterUser": "ユーザーを登録できませんでした"
}
},
"invite": {
@@ -318,7 +401,8 @@
"userAlreadyVerified": "ユーザーは既に確認済みです!",
"couldNotResendInvite": "招待を再送信できませんでした",
"inviteSendFailed": "招待メールの送信に失敗しました",
"emailSendFailed": "確認メールの送信に失敗しました"
"emailSendFailed": "確認メールの送信に失敗しました",
"contextMissing": "エラーが発生しました。もう一度お試しください。"
}
},
"authenticator": {
@@ -360,6 +444,14 @@
"unknownContext": "ユーザーのコンテキストを取得できませんでした。最初にユーザー名を入力するか、検索パラメータとしてloginNameを指定してください。",
"sessionExpired": "現在のセッションが期限切れです。再度ログインしてください。",
"failedLoading": "データの読み込みに失敗しました。再試行してください。",
"tryagain": "再試行"
"tryagain": "再試行",
"couldNotContinueSession": "セッションを継続できませんでした - ユーザー情報が不足しています"
},
"zitadel": {
"errors": {
"errorOccured": "エラーが発生しました",
"multipleUsersFound": "複数のユーザーが見つかりました",
"userNotFound": "ユーザーがシステムに見つかりません"
}
}
}
+7 -7
View File
@@ -147,7 +147,9 @@
"couldNotLoadSession": "Nie można załadować sesji",
"couldNotLoadAuthMethods": "Nie można załadować metod uwierzytelniania",
"failedPrecondition": "Nie spełniono warunków wstępnych",
"sessionNotValid": "Sesja jest nieważna"
"sessionNotValid": "Sesja jest nieważna",
"passwordVerificationMissing": "Brak weryfikacji hasła.",
"passwordVerificationTooOld": "Weryfikacja hasła jest przestarzała. Proszę zweryfikować hasło ponownie."
}
},
"idp": {
@@ -274,10 +276,7 @@
"couldNotFindSession": "Nie można znaleźć sesji",
"couldNotUpdateSession": "Nie można zaktualizować sesji",
"userNotFound": "Użytkownik nie znaleziony w systemie",
"couldNotDetermineRedirect": "Nie można określić adresu URL przekierowania po weryfikacji klucza dostępu",
"couldNotSetSession": "Nie można ustawić sesji",
"couldNotGetUser": "Nie można pobrać danych użytkownika",
"couldNotRetrieveSessionCookie": "Nie można pobrać pliku cookie sesji"
"couldNotDetermineRedirect": "Nie można określić adresu URL przekierowania po weryfikacji klucza dostępu"
}
},
"set": {
@@ -409,7 +408,8 @@
"userAlreadyVerified": "Użytkownik jest już zweryfikowany!",
"couldNotResendInvite": "Nie udało się ponownie wysłać zaproszenia",
"inviteSendFailed": "Nie udało się wysłać wiadomości e-mail z zaproszeniem",
"emailSendFailed": "Nie udało się wysłać wiadomości e-mail weryfikacyjnej"
"emailSendFailed": "Nie udało się wysłać wiadomości e-mail weryfikacyjnej",
"contextMissing": "Wystąpił błąd. Spróbuj ponownie."
}
},
"authenticator": {
@@ -452,6 +452,6 @@
"sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.",
"failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.",
"tryagain": "Spróbuj ponownie",
"couldNotContinueSession": "Nie można kontynuować sesji - brakujące informacje o użytkowniku"
"couldNotContinueSession": "Nie udało się kontynuować sesji - brak informacji o użytkowniku"
}
}
+6 -6
View File
@@ -147,7 +147,9 @@
"couldNotLoadSession": "Не удалось загрузить сессию",
"couldNotLoadAuthMethods": "Не удалось загрузить методы аутентификации",
"failedPrecondition": "Нарушено предварительное условие",
"sessionNotValid": "Сессия недействительна"
"sessionNotValid": "Сессия недействительна",
"passwordVerificationMissing": "Отсутствует проверка пароля.",
"passwordVerificationTooOld": "Проверка пароля устарела. Пожалуйста, подтвердите пароль еще раз."
}
},
"idp": {
@@ -274,10 +276,7 @@
"couldNotFindSession": "Не удалось найти сеанс",
"couldNotUpdateSession": "Не удалось обновить сеанс",
"userNotFound": "Пользователь не найден в системе",
"couldNotDetermineRedirect": "Не удалось определить URL перенаправления после проверки пасскей",
"couldNotSetSession": "Не удалось установить сеанс",
"couldNotGetUser": "Не удалось получить данные пользователя",
"couldNotRetrieveSessionCookie": "Не удалось получить cookie сеанса"
"couldNotDetermineRedirect": "Не удалось определить URL перенаправления после проверки пасскей"
}
},
"set": {
@@ -409,7 +408,8 @@
"userAlreadyVerified": "Пользователь уже подтверждён!",
"couldNotResendInvite": "Не удалось повторно отправить приглашение",
"inviteSendFailed": "Не удалось отправить письмо с приглашением",
"emailSendFailed": "Не удалось отправить письмо с подтверждением"
"emailSendFailed": "Не удалось отправить письмо с подтверждением",
"contextMissing": "Произошла ошибка. Пожалуйста, попробуйте еще раз."
}
},
"authenticator": {
+40 -16
View File
@@ -147,7 +147,9 @@
"couldNotLoadSession": "Oturum yüklenemedi",
"couldNotLoadAuthMethods": "Kimlik doğrulama yöntemleri yüklenemedi",
"failedPrecondition": "Ön koşul başarısız",
"sessionNotValid": "Oturum geçerli değil"
"sessionNotValid": "Oturum geçerli değil",
"passwordVerificationMissing": "Şifre doğrulaması eksik.",
"passwordVerificationTooOld": "Şifre doğrulaması çok eski. Lütfen şifrenizi tekrar doğrulayın."
}
},
"idp": {
@@ -159,18 +161,6 @@
"signInWithAzureAD": "AzureAD ile Giriş",
"signInWithGithub": "GitHub ile Giriş",
"signInWithGitlab": "GitLab ile Giriş",
"loginSuccess": {
"title": "Giriş başarılı",
"description": "Başarıyla giriş yaptınız!"
},
"linkingSuccess": {
"title": "Hesap bağlandı",
"description": "Hesabınızı başarıyla bağladınız!"
},
"registerSuccess": {
"title": "Kayıt başarılı",
"description": "Başarıyla kayıt oldunuz!"
},
"loginError": {
"title": "Giriş başarısız",
"description": "Giriş yapılmaya çalışılırken bir hata oluştu."
@@ -194,6 +184,23 @@
"description": "Kayıt işlemini tamamlayamadık.",
"info": "Kayıt için organizasyon belirlenemedi. Lütfen yardım için yöneticinizle iletişime geçin.",
"backToLogin": "Girişe Dön"
},
"processing": {
"message": "Kimlik doğrulama işleniyor...",
"noRedirect": "Sunucudan yönlendirme veya hata dönmedi",
"unexpectedError": "Beklenmeyen bir hata oluştu"
},
"errors": {
"missingParameters": "Eksik gerekli parametreler",
"missingIdpInfo": "IDP bilgisi eksik",
"idpNotFound": "Kimlik sağlayıcı bulunamadı",
"linkingNotAllowed": "Bu kimlik sağlayıcı için bağlantıya izin verilmiyor",
"linkingFailed": "Kimlik sağlayıcı hesaba bağlanamadı",
"autoLinkingFailed": "Hesap otomatik olarak bağlanamadı",
"userCreationFailed": "Kullanıcı hesabı oluşturulamadı",
"orgResolutionFailed": "Kayıt için organizasyon belirlenemedi",
"sessionCreationFailed": "Oturum oluşturulamadı veya yönlendirme belirlenemedi",
"unknownError": "Bilinmeyen bir hata oluştu"
}
},
"ldap": {
@@ -257,7 +264,20 @@
"title": "Passkey ile kimlik doğrulama",
"description": "Cihazınız parmak izinizi, yüzünüzü veya ekran kilidinizi isteyecektir",
"usePassword": "Şifre kullan",
"submit": "Devam"
"submit": "Devam",
"errors": {
"couldNotRequestChallenge": "Passkey meydan okuması istenemedi",
"couldNotVerifyPasskey": "Passkey doğrulanamadı",
"noResponseReceived": "Passkey doğrulaması başarısız - yanıt alınamadı",
"noRedirectProvided": "Passkey doğrulaması tamamlandı ancak yönlendirme sağlanmadı",
"couldNotRetrievePasskey": "Passkey alınırken bir hata oluştu",
"verificationCancelled": "Passkey doğrulaması iptal edildi",
"verificationFailed": "Passkey doğrulaması sırasında bir hata oluştu",
"couldNotFindSession": "Oturum bulunamadı",
"couldNotUpdateSession": "Oturum güncellenemedi",
"userNotFound": "Kullanıcı sistemde bulunamadı",
"couldNotDetermineRedirect": "Passkey doğrulamasından sonra yönlendirme URL'si belirlenemedi"
}
},
"set": {
"title": "Passkey kur",
@@ -386,7 +406,10 @@
"couldNotCreateSession": "Oturum oluşturulamadı",
"noHostFound": "Host bulunamadı",
"userAlreadyVerified": "Kullanıcı zaten doğrulanmış!",
"couldNotResendInvite": "Davet yeniden gönderilemedi"
"couldNotResendInvite": "Davet yeniden gönderilemedi",
"contextMissing": "Bir hata oluştu. Lütfen tekrar deneyin.",
"inviteSendFailed": "Davet e-postası gönderilemedi",
"emailSendFailed": "Doğrulama e-postası gönderilemedi"
}
},
"authenticator": {
@@ -428,6 +451,7 @@
"unknownContext": "Kullanıcının bağlamı alınamadı. Önce kullanıcı adını girdiğinizden emin olun veya searchParam olarak bir loginName sağlayın.",
"sessionExpired": "Mevcut oturumunuzun süresi doldu. Lütfen tekrar giriş yapın.",
"failedLoading": "Veri yüklenemedi. Lütfen tekrar deneyin.",
"tryagain": "Tekrar Dene"
"tryagain": "Tekrar Dene",
"couldNotContinueSession": "Oturuma devam edilemedi - kullanıcı bilgileri eksik"
}
}
+5 -2
View File
@@ -147,7 +147,9 @@
"couldNotLoadSession": "Не вдалося завантажити сеанс",
"couldNotLoadAuthMethods": "Не вдалося завантажити методи автентифікації",
"failedPrecondition": "Не виконано передумову",
"sessionNotValid": "Сеанс недійсний"
"sessionNotValid": "Сеанс недійсний",
"passwordVerificationMissing": "Відсутня перевірка пароля.",
"passwordVerificationTooOld": "Перевірка пароля застаріла. Будь ласка, перевірте пароль ще раз."
}
},
"idp": {
@@ -406,7 +408,8 @@
"userAlreadyVerified": "Користувача вже підтверджено!",
"couldNotResendInvite": "Не вдалося повторно надіслати запрошення",
"inviteSendFailed": "Не вдалося надіслати лист-запрошення",
"emailSendFailed": "Не вдалося надіслати лист підтвердження"
"emailSendFailed": "Не вдалося надіслати лист підтвердження",
"contextMissing": "Сталася помилка. Будь ласка, спробуйте ще раз."
}
},
"authenticator": {
+6 -6
View File
@@ -147,7 +147,9 @@
"couldNotLoadSession": "无法加载会话",
"couldNotLoadAuthMethods": "无法加载验证方法",
"failedPrecondition": "前置条件失败",
"sessionNotValid": "会话无效"
"sessionNotValid": "会话无效",
"passwordVerificationMissing": "缺少密码验证。",
"passwordVerificationTooOld": "密码验证已过期。请重新通过验证。"
}
},
"idp": {
@@ -274,10 +276,7 @@
"couldNotFindSession": "找不到会话",
"couldNotUpdateSession": "无法更新会话",
"userNotFound": "系统中未找到用户",
"couldNotDetermineRedirect": "密钥验证后无法确定重定向URL",
"couldNotSetSession": "无法设置会话",
"couldNotGetUser": "无法检索用户数据",
"couldNotRetrieveSessionCookie": "无法检索会话Cookie"
"couldNotDetermineRedirect": "密钥验证后无法确定重定向URL"
}
},
"set": {
@@ -409,7 +408,8 @@
"userAlreadyVerified": "用户已验证!",
"couldNotResendInvite": "无法重新发送邀请",
"inviteSendFailed": "发送邀请邮件失败",
"emailSendFailed": "发送验证邮件失败"
"emailSendFailed": "发送验证邮件失败",
"contextMissing": "发生错误。请重试。"
}
},
"authenticator": {
+1
View File
@@ -60,6 +60,7 @@
"@types/node": "^22.19.3",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/uuid": "^10.0.0",
"@types/tinycolor2": "^1.4.6",
"@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0",
@@ -77,6 +77,11 @@ export default async function Page(props: { searchParams: Promise<Record<string
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
if (!recent) {
return undefined;
}
const sessionResponse = await getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token });
return getAuthMethodsAndUser(sessionResponse.session);
}
+8 -10
View File
@@ -28,25 +28,22 @@ export default async function Page(props: { searchParams: Promise<Record<string
let defaultOrganization;
if (!organization) {
const org: Organization | null = await getDefaultOrg({ serviceConfig, });
const org: Organization | null = await getDefaultOrg({ serviceConfig });
if (org) {
defaultOrganization = org.id;
}
}
const loginSettings = await getLoginSettings({ serviceConfig, organization: organization ?? defaultOrganization,
});
const loginSettings = await getLoginSettings({ serviceConfig, organization: organization ?? defaultOrganization });
const contextLoginSettings = await getLoginSettings({ serviceConfig, organization,
});
const identityProviders = await getActiveIdentityProviders({ serviceConfig, orgId: organization ?? defaultOrganization,
const identityProviders = await getActiveIdentityProviders({
serviceConfig,
orgId: organization ?? defaultOrganization,
}).then((resp) => {
return resp.identityProviders;
});
const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization,
});
const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization });
return (
<DynamicTheme branding={branding}>
@@ -64,7 +61,8 @@ export default async function Page(props: { searchParams: Promise<Record<string
loginName={loginName}
requestId={requestId}
organization={organization} // stick to "organization" as we still want to do user discovery based on the searchParams not the default organization, later the organization is determined by the found user
loginSettings={contextLoginSettings}
defaultOrganization={defaultOrganization}
loginSettings={loginSettings}
suffix={suffix}
submit={submit}
allowRegister={!!loginSettings?.allowRegister}
+16 -14
View File
@@ -26,18 +26,19 @@ export default async function Page(props: { searchParams: Promise<Record<string
const { serviceConfig } = getServiceConfig(_headers);
const sessionFactors = sessionId
? await loadSessionById(serviceConfig.baseUrl, sessionId, organization)
: await loadSessionByLoginname(serviceConfig.baseUrl, loginName, organization);
? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(loginName, organization);
async function loadSessionByLoginname(serviceUrl: string, loginName?: string, organization?: string) {
return loadMostRecentSession({ serviceConfig, sessionParams: {
async function loadSessionByLoginname(loginName?: string, organization?: string) {
return loadMostRecentSession({
serviceConfig,
sessionParams: {
loginName,
organization,
},
}).then((session) => {
if (session && session.factors?.user?.id) {
return listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id,
}).then((methods) => {
return listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id }).then((methods) => {
return {
factors: session?.factors,
authMethods: methods.authMethodTypes ?? [],
@@ -47,14 +48,16 @@ export default async function Page(props: { searchParams: Promise<Record<string
});
}
async function loadSessionById(host: string, sessionId: string, organization?: string) {
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({ serviceConfig, sessionId: recent.id,
sessionToken: recent.token,
}).then((response) => {
if (!recent) {
return undefined;
}
return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token }).then((response) => {
if (response?.session && response.session.factors?.user?.id) {
return listAuthenticationMethodTypes({ serviceConfig, userId: response.session.factors.user.id,
}).then((methods) => {
return listAuthenticationMethodTypes({ serviceConfig, userId: response.session.factors.user.id }).then((methods) => {
return {
factors: response.session?.factors,
authMethods: methods.authMethodTypes ?? [],
@@ -64,8 +67,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
});
}
const branding = await getBrandingSettings({ serviceConfig, organization,
});
const branding = await getBrandingSettings({ serviceConfig, organization });
return (
<DynamicTheme branding={branding}>
+20 -11
View File
@@ -30,8 +30,12 @@ function isSessionValid(session: Partial<Session>): {
verifiedAt?: Timestamp;
} {
const validPassword = session?.factors?.password?.verifiedAt;
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
const validPasskey =
session?.factors?.webAuthN?.verifiedAt && !!session?.factors?.webAuthN?.userVerified
? session?.factors?.webAuthN?.verifiedAt
: undefined;
const validIDP = session?.factors?.intent?.verifiedAt;
const stillValid = session.expirationDate ? timestampDate(session.expirationDate) > new Date() : true;
const verifiedAt = validPassword || validPasskey || validIDP;
@@ -59,8 +63,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
throw Error("Could not get user id from session");
}
return listAuthenticationMethodTypes({ serviceConfig, userId,
}).then((methods) => {
return listAuthenticationMethodTypes({ serviceConfig, userId }).then((methods) => {
return getUserByID({ serviceConfig, userId }).then((user) => {
const humanUser = user.user?.type.case === "human" ? user.user?.type.value : undefined;
@@ -77,7 +80,9 @@ export default async function Page(props: { searchParams: Promise<Record<string
}
async function loadSessionByLoginname(loginName?: string, organization?: string) {
return loadMostRecentSession({ serviceConfig, sessionParams: {
return loadMostRecentSession({
serviceConfig,
sessionParams: {
loginName,
organization,
},
@@ -88,19 +93,23 @@ export default async function Page(props: { searchParams: Promise<Record<string
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({ serviceConfig, sessionId: recent.id,
sessionToken: recent.token,
}).then((sessionResponse) => {
if (!recent) {
return undefined;
}
return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token }).then((sessionResponse) => {
return getAuthMethodsAndUser(sessionResponse.session);
});
}
const branding = await getBrandingSettings({ serviceConfig, organization,
});
const loginSettings = await getLoginSettings({ serviceConfig, organization: sessionWithData.factors?.user?.organizationId,
const branding = await getBrandingSettings({ serviceConfig, organization });
const loginSettings = await getLoginSettings({
serviceConfig,
organization: sessionWithData?.factors?.user?.organizationId,
});
const { valid } = isSessionValid(sessionWithData);
const { valid } = sessionWithData ? isSessionValid(sessionWithData) : { valid: false };
return (
<DynamicTheme branding={branding}>
@@ -44,6 +44,11 @@ export default async function Page(props: {
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
if (!recent) {
return undefined;
}
return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token }).then((response) => {
if (response?.session) {
return response.session;
+36 -12
View File
@@ -6,7 +6,8 @@ import { UserAvatar } from "@/components/user-avatar";
import { getSessionCookieById } from "@/lib/cookies";
import { getServiceConfig } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings, getSession } from "@/lib/zitadel";
import { getBrandingSettings, getDefaultOrg, getSession } from "@/lib/zitadel";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
@@ -24,23 +25,44 @@ export default async function Page(props: { searchParams: Promise<Record<string
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const sessionFactors = sessionId
? await loadSessionById(serviceConfig.baseUrl, sessionId, organization)
: await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization },
});
let defaultOrganization;
if (!organization) {
const org: Organization | null = await getDefaultOrg({ serviceConfig });
async function loadSessionById(serviceUrl: string, sessionId: string, organization?: string) {
if (org) {
defaultOrganization = org.id;
}
}
let sessionFactors = sessionId ? await loadSessionById(sessionId, organization) : undefined;
if (!sessionFactors && !sessionId) {
sessionFactors = await loadMostRecentSession({
serviceConfig,
sessionParams: { loginName, organization },
}).catch(() => {
// ignore error
return undefined;
});
}
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({ serviceConfig, sessionId: recent.id,
sessionToken: recent.token,
}).then((response) => {
if (!recent) {
return undefined;
}
return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token }).then((response) => {
if (response?.session) {
return response.session;
}
});
}
const branding = await getBrandingSettings({ serviceConfig, organization,
const branding = await getBrandingSettings({
serviceConfig,
organization: organization ?? sessionFactors?.factors?.user?.organizationId ?? defaultOrganization,
});
return (
@@ -54,14 +76,16 @@ export default async function Page(props: { searchParams: Promise<Record<string
<Translated i18nKey="verify.description" namespace="passkey" />
</p>
{sessionFactors && (
{sessionFactors ? (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
) : loginName ? (
<UserAvatar loginName={loginName} displayName={loginName} showDropdown searchParams={searchParams}></UserAvatar>
) : null}
</div>
<div className="w-full">
@@ -24,19 +24,24 @@ export default async function Page(props: { searchParams: Promise<Record<string
const { loginName, organization, requestId } = searchParams;
// also allow no session to be found (ignoreUnkownUsername)
const sessionFactors = await loadMostRecentSession({ serviceConfig, sessionParams: {
const sessionFactors = await loadMostRecentSession({
serviceConfig,
sessionParams: {
loginName,
organization,
},
});
const branding = await getBrandingSettings({ serviceConfig, organization,
const branding = await getBrandingSettings({ serviceConfig, organization });
const passwordComplexity = await getPasswordComplexitySettings({
serviceConfig,
organization: sessionFactors?.factors?.user?.organizationId,
});
const passwordComplexity = await getPasswordComplexitySettings({ serviceConfig, organization: sessionFactors?.factors?.user?.organizationId,
});
const loginSettings = await getLoginSettings({ serviceConfig, organization: sessionFactors?.factors?.user?.organizationId,
const loginSettings = await getLoginSettings({
serviceConfig,
organization: sessionFactors?.factors?.user?.organizationId,
});
return (
@@ -58,14 +63,16 @@ export default async function Page(props: { searchParams: Promise<Record<string
</div>
)}
{sessionFactors && (
{sessionFactors ? (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
) : loginName ? (
<UserAvatar loginName={loginName} displayName={loginName} showDropdown searchParams={searchParams}></UserAvatar>
) : null}
</div>
<div className="w-full">
+15 -6
View File
@@ -25,7 +25,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
let defaultOrganization;
if (!organization) {
const org: Organization | null = await getDefaultOrg({ serviceConfig, });
const org: Organization | null = await getDefaultOrg({ serviceConfig });
if (org) {
defaultOrganization = org.id;
@@ -35,7 +35,9 @@ export default async function Page(props: { searchParams: Promise<Record<string
// also allow no session to be found (ignoreUnkownUsername)
let sessionFactors;
try {
sessionFactors = await loadMostRecentSession({ serviceConfig, sessionParams: {
sessionFactors = await loadMostRecentSession({
serviceConfig,
sessionParams: {
loginName,
organization,
},
@@ -45,9 +47,13 @@ export default async function Page(props: { searchParams: Promise<Record<string
console.warn(error);
}
const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization,
const branding = await getBrandingSettings({
serviceConfig,
organization: organization ?? sessionFactors?.factors?.user?.organizationId ?? defaultOrganization,
});
const loginSettings = await getLoginSettings({ serviceConfig, organization: organization ?? defaultOrganization,
const loginSettings = await getLoginSettings({
serviceConfig,
organization: organization ?? sessionFactors?.factors?.user?.organizationId ?? defaultOrganization,
});
return (
@@ -60,14 +66,16 @@ export default async function Page(props: { searchParams: Promise<Record<string
<Translated i18nKey="verify.description" namespace="password" />
</p>
{sessionFactors && (
{sessionFactors ? (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
) : loginName ? (
<UserAvatar loginName={loginName} displayName={loginName} showDropdown searchParams={searchParams}></UserAvatar>
) : null}
</div>
<div className="w-full">
@@ -85,6 +93,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
loginName={loginName}
requestId={requestId}
organization={organization} // stick to "organization" as we still want to do user discovery based on the searchParams not the default organization, later the organization is determined by the found user
defaultOrganization={defaultOrganization}
loginSettings={loginSettings}
/>
)}
@@ -4,13 +4,22 @@ import { SetPasswordForm } from "@/components/set-password-form";
import { Translated } from "@/components/translated";
import { UserAvatar } from "@/components/user-avatar";
import { getServiceConfig } from "@/lib/service-url";
import { UNKNOWN_USER_ID } from "@/lib/constants";
import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings, getLoginSettings, getPasswordComplexitySettings, getUserByID } from "@/lib/zitadel";
import {
getBrandingSettings,
getDefaultOrg,
getLoginSettings,
getPasswordComplexitySettings,
getUserByID,
searchUsers,
} from "@/lib/zitadel";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("password");
@@ -20,39 +29,74 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
const searchParams = await props.searchParams;
const { userId, loginName, organization, requestId, code, initial } = searchParams;
let { userId, loginName, organization, requestId, code, initial } = searchParams;
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
let defaultOrganization;
if (!organization) {
const org: Organization | null = await getDefaultOrg({ serviceConfig });
if (org) {
defaultOrganization = org.id;
}
}
// also allow no session to be found (ignoreUnkownUsername)
let session: Session | undefined;
if (loginName) {
session = await loadMostRecentSession({ serviceConfig, sessionParams: {
session = await loadMostRecentSession({
serviceConfig,
sessionParams: {
loginName,
organization,
},
});
}
const branding = await getBrandingSettings({ serviceConfig, organization,
const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization });
const passwordComplexity = await getPasswordComplexitySettings({
serviceConfig,
organization: organization ?? session?.factors?.user?.organizationId ?? defaultOrganization,
});
const passwordComplexity = await getPasswordComplexitySettings({ serviceConfig, organization: session?.factors?.user?.organizationId,
const loginSettings = await getLoginSettings({
serviceConfig,
organization: organization ?? session?.factors?.user?.organizationId ?? defaultOrganization,
});
const loginSettings = await getLoginSettings({ serviceConfig, organization,
});
if (!loginSettings) {
return (
<DynamicTheme branding={branding}>
<div className="mx-auto flex max-w-sm flex-col space-y-4 pt-4">
<Alert>
<Translated i18nKey="errors.couldNotGetLoginSettings" namespace="loginname" />
</Alert>
</div>
</DynamicTheme>
);
}
let user: User | undefined;
let displayName: string | undefined;
if (userId) {
const userResponse = await getUserByID({ serviceConfig, userId,
});
const userResponse = await getUserByID({ serviceConfig, userId });
user = userResponse.user;
} else if (loginName) {
const users = await searchUsers({
serviceConfig,
searchValue: loginName,
loginSettings: loginSettings,
organizationId: organization,
});
if (user?.type.case === "human") {
displayName = (user.type.value as HumanUser).profile?.displayName;
if (users.result && users.result.length === 1) {
const foundUser = users.result[0];
userId = foundUser.userId;
user = foundUser;
} else if (loginSettings?.ignoreUnknownUsernames) {
// Prevent enumeration by pretending we found a user
userId = UNKNOWN_USER_ID;
}
}
@@ -80,13 +124,8 @@ export default async function Page(props: { searchParams: Promise<Record<string
showDropdown
searchParams={searchParams}
></UserAvatar>
) : user ? (
<UserAvatar
loginName={user?.preferredLoginName}
displayName={displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
) : loginName ? (
<UserAvatar loginName={loginName} displayName={loginName} showDropdown searchParams={searchParams}></UserAvatar>
) : null}
</div>
@@ -97,13 +136,16 @@ export default async function Page(props: { searchParams: Promise<Record<string
</Alert>
)}
{passwordComplexity && (loginName ?? user?.preferredLoginName) && (userId ?? session?.factors?.user?.id) ? (
{passwordComplexity &&
(loginName ?? user?.preferredLoginName) &&
(userId ?? session?.factors?.user?.id ?? (loginSettings?.ignoreUnknownUsernames ? UNKNOWN_USER_ID : undefined)) ? (
<SetPasswordForm
code={code}
userId={userId ?? (session?.factors?.user?.id as string)}
userId={userId ?? (session?.factors?.user?.id as string) ?? UNKNOWN_USER_ID}
loginName={loginName ?? (user?.preferredLoginName as string)}
requestId={requestId}
organization={organization}
defaultOrganization={defaultOrganization}
passwordComplexitySettings={passwordComplexity}
codeRequired={!(initial === "true")}
/>
+31 -29
View File
@@ -20,9 +20,12 @@ export async function generateMetadata(): Promise<Metadata> {
async function loadSessionById(serviceConfig: ServiceConfig, sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({ serviceConfig, sessionId: recent.id,
sessionToken: recent.token,
}).then((response) => {
if (!recent) {
return undefined;
}
return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token }).then((response) => {
if (response?.session) {
return response.session;
}
@@ -37,8 +40,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
const { loginName, requestId, organization, sessionId } = searchParams;
const branding = await getBrandingSettings({ serviceConfig, organization,
});
const branding = await getBrandingSettings({ serviceConfig, organization });
// complete device authorization flow if device requestId is present
if (requestId && requestId.startsWith("device_")) {
@@ -49,36 +51,36 @@ export default async function Page(props: { searchParams: Promise<any> }) {
organization: organization,
});
await completeDeviceAuthorization(requestId.replace("device_", ""), {
sessionId: cookie.id,
sessionToken: cookie.token,
}).catch((err) => {
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="error.title" namespace="signedin" />
</h1>
<p className="ztdl-p mb-6 block">
<Translated i18nKey="error.description" namespace="signedin" />
</p>
<Alert>{err.message}</Alert>
</div>
<div className="w-full"></div>
</DynamicTheme>
);
});
if (cookie) {
await completeDeviceAuthorization(requestId.replace("device_", ""), {
sessionId: cookie.id,
sessionToken: cookie.token,
}).catch((err) => {
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="error.title" namespace="signedin" />
</h1>
<p className="ztdl-p mb-6 block">
<Translated i18nKey="error.description" namespace="signedin" />
</p>
<Alert>{err.message}</Alert>
</div>
<div className="w-full"></div>
</DynamicTheme>
);
});
}
}
const sessionFactors = sessionId
? await loadSessionById(serviceConfig, sessionId, organization)
: await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization },
});
: await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization } });
let loginSettings;
if (!requestId) {
loginSettings = await getLoginSettings({ serviceConfig, organization,
});
loginSettings = await getLoginSettings({ serviceConfig, organization });
}
return (
@@ -93,7 +95,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
<UserAvatar
loginName={loginName ?? sessionFactors?.factors?.user?.loginName}
displayName={sessionFactors?.factors?.user?.displayName}
displayName={sessionFactors?.factors?.user?.displayName ?? loginName}
showDropdown={!(requestId && requestId.startsWith("device_"))}
searchParams={searchParams}
/>
+8 -7
View File
@@ -24,19 +24,20 @@ export default async function Page(props: { searchParams: Promise<Record<string
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const branding = await getBrandingSettings({ serviceConfig, organization,
});
const branding = await getBrandingSettings({ serviceConfig, organization });
const sessionFactors = sessionId
? await loadSessionById(sessionId, organization)
: await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization },
});
: await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization } });
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({ serviceConfig, sessionId: recent.id,
sessionToken: recent.token,
}).then((response) => {
if (!recent) {
return undefined;
}
return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token }).then((response) => {
if (response?.session) {
return response.session;
}
+64 -13
View File
@@ -3,12 +3,14 @@ import { DynamicTheme } from "@/components/dynamic-theme";
import { Translated } from "@/components/translated";
import { UserAvatar } from "@/components/user-avatar";
import { VerifyForm } from "@/components/verify-form";
import { UNKNOWN_USER_ID } from "@/lib/constants";
import { getPublicHostWithProtocol } from "@/lib/server/host";
import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify";
import { getServiceConfig } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings, getUserByID } from "@/lib/zitadel";
import { getBrandingSettings, getLoginSettings, getUserByID, searchUsers } from "@/lib/zitadel";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
@@ -32,6 +34,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
let user: User | undefined;
let human: HumanUser | undefined;
let id: string | undefined;
let loginSettings: LoginSettings | undefined;
let error: string | undefined;
@@ -72,6 +75,13 @@ export default async function Page(props: { searchParams: Promise<any> }) {
loginName,
organization,
},
}).catch(async (error) => {
loginSettings = await getLoginSettings({ serviceConfig, organization });
if (!loginSettings?.ignoreUnknownUsernames) {
console.error("loadMostRecentSession failed", error);
}
// ignore error, as we might not have a session yet
return undefined;
});
if (doSend && sessionFactors?.factors?.user?.id) {
@@ -93,8 +103,39 @@ export default async function Page(props: { searchParams: Promise<any> }) {
id = userId ?? sessionFactors?.factors?.user?.id;
if (!id) {
throw Error("Failed to get user id");
if (!id && loginName) {
if (!loginSettings) {
loginSettings = await getLoginSettings({ serviceConfig, organization });
}
if (!loginSettings) {
console.error("loginSettings not found");
return;
}
const users = await searchUsers({
serviceConfig,
searchValue: loginName,
loginSettings: loginSettings,
organizationId: organization,
});
if (users.result && users.result.length === 1) {
const foundUser = users.result[0];
id = foundUser.userId;
user = foundUser;
if (user.type.case === "human") {
human = user.type.value as HumanUser;
}
// If we found the user and need to send email, do it now
if (doSend && id) {
await sendEmail(id);
}
} else if (loginSettings?.ignoreUnknownUsernames) {
// Prevent enumeration by pretending we found a user
id = UNKNOWN_USER_ID;
}
}
const params = new URLSearchParams({
@@ -132,8 +173,16 @@ export default async function Page(props: { searchParams: Promise<any> }) {
searchParams={searchParams}
></UserAvatar>
) : (
user && (
<UserAvatar loginName={user.preferredLoginName} displayName={human?.profile?.displayName} showDropdown={false} />
(user || loginName) && (
<UserAvatar
loginName={loginName ?? user?.preferredLoginName}
displayName={
!loginSettings?.ignoreUnknownUsernames
? human?.profile?.displayName
: (loginName ?? user?.preferredLoginName)
}
showDropdown={false}
/>
)
)}
</div>
@@ -163,14 +212,16 @@ export default async function Page(props: { searchParams: Promise<any> }) {
</div>
)}
<VerifyForm
loginName={loginName}
organization={organization}
userId={id}
code={code}
isInvite={invite === "true"}
requestId={requestId}
/>
{id && (
<VerifyForm
loginName={loginName}
organization={organization}
userId={id}
code={code}
isInvite={invite === "true"}
requestId={requestId}
/>
)}
</div>
</DynamicTheme>
);
+3 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { completeFlowOrGetUrl } from "@/lib/client";
import { updateSession } from "@/lib/server/session";
import { updateOrCreateSession } from "@/lib/server/session";
import { create } from "@zitadel/client";
import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
@@ -94,7 +94,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
}
setLoading(true);
const response = await updateSession({
const response = await updateOrCreateSession({
loginName,
sessionId,
organization,
@@ -151,7 +151,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
});
}
const response = await updateSession({
const response = await updateOrCreateSession({
loginName,
sessionId,
organization,
@@ -17,7 +17,7 @@ vi.mock("@/lib/server/passkeys", () => ({
}));
vi.mock("@/lib/server/session", () => ({
updateSession: vi.fn(),
updateOrCreateSession: vi.fn(),
}));
// Mock navigator.credentials
@@ -67,10 +67,10 @@ describe("LoginPasskey Component", () => {
mockCredentialsGet.mockClear();
const { sendPasskey } = await import("@/lib/server/passkeys");
const { updateSession } = await import("@/lib/server/session");
const { updateOrCreateSession } = await import("@/lib/server/session");
mockSendPasskey = vi.mocked(sendPasskey);
mockUpdateSession = vi.mocked(updateSession);
mockUpdateSession = vi.mocked(updateOrCreateSession);
});
describe("Initialization and Challenge Request", () => {
+13 -12
View File
@@ -2,7 +2,7 @@
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
import { sendPasskey } from "@/lib/server/passkeys";
import { updateSession } from "@/lib/server/session";
import { updateOrCreateSession } from "@/lib/server/session";
import { create, JsonObject } from "@zitadel/client";
import { RequestChallengesSchema, UserVerificationRequirement } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
@@ -38,7 +38,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
if (!initialized.current) {
initialized.current = true;
setLoading(true);
updateSessionForChallenge()
updateOrCreateSessionForChallenge()
.then((response) => {
const pK = response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions?.publicKey;
@@ -50,7 +50,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
return submitLoginAndContinue(pK)
.catch((error) => {
setError(error);
setError(error instanceof Error ? error.message : String(error));
return;
})
.finally(() => {
@@ -58,7 +58,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
});
})
.catch((error) => {
setError(error);
setError(error instanceof Error ? error.message : String(error));
return;
})
.finally(() => {
@@ -68,14 +68,14 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function updateSessionForChallenge(
async function updateOrCreateSessionForChallenge(
userVerificationRequirement: number = login
? UserVerificationRequirement.REQUIRED
: UserVerificationRequirement.DISCOURAGED,
) {
setError("");
setLoading(true);
const session = await updateSession({
const sessionResponse = await updateOrCreateSession({
loginName,
sessionId,
organization,
@@ -87,7 +87,8 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
}),
requestId,
})
.catch(() => {
.catch((error) => {
console.error(error);
setError(t("verify.errors.couldNotRequestChallenge"));
return;
})
@@ -95,12 +96,12 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
setLoading(false);
});
if (session && "error" in session && session.error) {
setError(session.error);
if (sessionResponse && "error" in sessionResponse && sessionResponse.error) {
setError(sessionResponse.error);
return;
}
return session;
return sessionResponse;
}
async function submitLogin(data: JsonObject) {
@@ -238,7 +239,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
variant={ButtonVariants.Primary}
disabled={loading}
onClick={async () => {
const response = await updateSessionForChallenge().finally(() => {
const response = await updateOrCreateSessionForChallenge().finally(() => {
setLoading(false);
});
@@ -253,7 +254,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
return submitLoginAndContinue(pK)
.catch((error) => {
setError(error);
setError(error instanceof Error ? error.message : String(error));
return;
})
.finally(() => {
+6 -3
View File
@@ -23,10 +23,11 @@ type Props = {
loginSettings: LoginSettings | undefined;
loginName: string;
organization?: string;
defaultOrganization?: string;
requestId?: string;
};
export function PasswordForm({ loginSettings, loginName, organization, requestId }: Props) {
export function PasswordForm({ loginSettings, loginName, organization, defaultOrganization, requestId }: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onChange",
});
@@ -47,6 +48,7 @@ export function PasswordForm({ loginSettings, loginName, organization, requestId
const response = await sendPassword({
loginName,
organization,
defaultOrganization,
checks: create(ChecksSchema, {
password: { password: values.password },
}),
@@ -78,10 +80,11 @@ export function PasswordForm({ loginSettings, loginName, organization, requestId
const response = await resetPassword({
loginName,
organization,
defaultOrganization,
requestId,
})
.catch(() => {
setError(t("verify.errors.couldNotResetPassword"));
setError(t("errors.couldNotSendResetLink"));
return;
})
.finally(() => {
@@ -89,7 +92,7 @@ export function PasswordForm({ loginSettings, loginName, organization, requestId
});
if (response && "error" in response) {
setError(response.error);
setError(response.error as string);
return;
}
@@ -31,6 +31,7 @@ type Props = {
loginName: string;
userId: string;
organization?: string;
defaultOrganization?: string;
requestId?: string;
codeRequired: boolean;
};
@@ -38,6 +39,7 @@ type Props = {
export function SetPasswordForm({
passwordComplexitySettings,
organization,
defaultOrganization,
requestId,
loginName,
userId,
@@ -65,6 +67,7 @@ export function SetPasswordForm({
const response = await resetPassword({
loginName,
organization,
defaultOrganization,
requestId,
})
.catch(() => {
@@ -75,7 +78,7 @@ export function SetPasswordForm({
setLoading(false);
});
if (response && "error" in response) {
if (response && "error" in response && typeof response.error === "string") {
setError(response.error);
return;
}
@@ -83,9 +86,11 @@ export function SetPasswordForm({
async function submitPassword(values: Inputs) {
setLoading(true);
let payload: { userId: string; password: string; code?: string } = {
let payload: { userId: string; password: string; code?: string; organization?: string } = {
userId: userId,
password: values.password,
organization,
};
// this is not required for initial password setup
+13 -1
View File
@@ -22,12 +22,22 @@ type Props = {
requestId: string | undefined;
loginSettings: LoginSettings | undefined;
organization?: string;
defaultOrganization?: string;
suffix?: string;
submit: boolean;
allowRegister: boolean;
};
export function UsernameForm({ loginName, requestId, organization, suffix, loginSettings, submit, allowRegister }: Props) {
export function UsernameForm({
loginName,
requestId,
organization,
defaultOrganization,
suffix,
loginSettings,
submit,
allowRegister,
}: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onChange",
defaultValues: {
@@ -48,8 +58,10 @@ export function UsernameForm({ loginName, requestId, organization, suffix, login
const res = await sendLoginname({
loginName: values.loginName,
organization,
defaultOrganization,
requestId,
suffix,
ignoreUnknownUsernames: loginSettings?.ignoreUnknownUsernames,
})
.catch(() => {
setError(t("errors.internalError"));
+14 -2
View File
@@ -2,8 +2,9 @@
import { Alert, AlertType } from "@/components/alert";
import { resendVerification, sendVerification } from "@/lib/server/verify";
import { UNKNOWN_USER_ID } from "@/lib/constants";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { BackButton } from "./back-button";
@@ -45,6 +46,13 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
setError("");
setLoading(true);
// do not send code for dummy userid that is set to prevent user enumeration
if (userId === UNKNOWN_USER_ID) {
await new Promise((resolve) => setTimeout(resolve, 1000));
setLoading(false);
return;
}
const response = await resendVerification({
userId,
isInvite: isInvite,
@@ -65,8 +73,11 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
return response;
}
const processedCode = useRef<string | undefined>(undefined);
const fcn = useCallback(
async function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
setError("");
setLoading(true);
const response = await sendVerification({
@@ -98,7 +109,8 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
);
useEffect(() => {
if (code) {
if (code && code !== processedCode.current) {
processedCode.current = code;
fcn({ code });
}
}, [code, fcn]);
+1
View File
@@ -0,0 +1 @@
export const UNKNOWN_USER_ID = "000000000000000000";
+64 -47
View File
@@ -454,13 +454,14 @@ describe("cookies", () => {
const result = await getMostRecentSessionCookie();
expect(result.id).toBe("session-2");
expect(result).toBeDefined();
expect(result?.id).toBe("session-2");
});
it("should reject when no session cookie exists", async () => {
it("should return undefined when no session cookie exists", async () => {
mockCookies.get.mockReturnValue(undefined);
await expect(getMostRecentSessionCookie()).rejects.toBe("no session cookie found");
await expect(getMostRecentSessionCookie()).resolves.toBeUndefined();
});
it("should handle single session", async () => {
@@ -479,7 +480,8 @@ describe("cookies", () => {
const result = await getMostRecentSessionCookie();
expect(result.id).toBe("session-1");
expect(result).toBeDefined();
expect(result?.id).toBe("session-1");
});
});
@@ -511,8 +513,9 @@ describe("cookies", () => {
const result = await getSessionCookieById({ sessionId: "session-1" });
expect(result.id).toBe("session-1");
expect(result.loginName).toBe("use[email protected]");
expect(result).toBeDefined();
expect(result?.id).toBe("session-1");
expect(result?.loginName).toBe("[email protected]");
});
it("should filter by organization when provided", async () => {
@@ -525,19 +528,20 @@ describe("cookies", () => {
organization: "org-2",
});
expect(result.id).toBe("session-2");
expect(result.organization).toBe("org-2");
expect(result).toBeDefined();
expect(result?.id).toBe("session-2");
expect(result?.organization).toBe("org-2");
});
it("should reject if session not found", async () => {
it("should return undefined if session not found", async () => {
mockCookies.get.mockReturnValue({
value: JSON.stringify([session1]),
});
await expect(getSessionCookieById({ sessionId: "non-existent" })).rejects.toBeTypeOf("undefined");
await expect(getSessionCookieById({ sessionId: "non-existent" })).resolves.toBeUndefined();
});
it("should reject if organization doesn't match", async () => {
it("should return undefined if organization doesn't match", async () => {
mockCookies.get.mockReturnValue({
value: JSON.stringify([session1]),
});
@@ -547,13 +551,13 @@ describe("cookies", () => {
sessionId: "session-1",
organization: "wrong-org",
}),
).rejects.toBeTypeOf("undefined");
).resolves.toBeUndefined();
});
it("should reject when no session cookie exists", async () => {
it("should return undefined when no session cookie exists", async () => {
mockCookies.get.mockReturnValue(undefined);
await expect(getSessionCookieById({ sessionId: "session-1" })).rejects.toBeTypeOf("undefined");
await expect(getSessionCookieById({ sessionId: "session-1" })).resolves.toBeUndefined();
});
});
@@ -587,8 +591,9 @@ describe("cookies", () => {
loginName: "[email protected]",
});
expect(result.id).toBe("session-1");
expect(result.loginName).toBe("use[email protected]");
expect(result).toBeDefined();
expect(result?.id).toBe("session-1");
expect(result?.loginName).toBe("[email protected]");
});
it("should filter by organization when provided", async () => {
@@ -601,24 +606,23 @@ describe("cookies", () => {
organization: "org-2",
});
expect(result.id).toBe("session-2");
expect(result.organization).toBe("org-2");
expect(result).toBeDefined();
expect(result?.id).toBe("session-2");
expect(result?.organization).toBe("org-2");
});
it("should reject if session not found", async () => {
it("should return undefined if session not found", async () => {
mockCookies.get.mockReturnValue({
value: JSON.stringify([session1]),
});
await expect(getSessionCookieByLoginName({ loginName: "[email protected]" })).rejects.toBe(
"no cookie found with loginName: [email protected]",
);
await expect(getSessionCookieByLoginName({ loginName: "[email protected]" })).resolves.toBeUndefined();
});
it("should reject when no session cookie exists", async () => {
it("should return undefined when no session cookie exists", async () => {
mockCookies.get.mockReturnValue(undefined);
await expect(getSessionCookieByLoginName({ loginName: "[email protected]" })).rejects.toBe("no session cookie found");
await expect(getSessionCookieByLoginName({ loginName: "[email protected]" })).resolves.toBeUndefined();
});
});
@@ -801,6 +805,36 @@ describe("cookies", () => {
expect(result.id).toBe("session-2");
});
it("should return undefined when no matching session found", async () => {
const session1: Cookie = {
id: "session-1",
token: "token-1",
loginName: "[email protected]",
creationTs: "1700000000000",
expirationTs: "1800000000000",
changeTs: "1700000000000",
};
const session2: Cookie = {
id: "session-2",
token: "token-2",
loginName: "[email protected]",
creationTs: "1700000001000",
expirationTs: "1800000001000",
changeTs: "1700000005000",
};
mockCookies.get.mockReturnValue({
value: JSON.stringify([session1, session2]),
});
await expect(
getMostRecentCookieWithLoginname({
loginName: "[email protected]",
}),
).resolves.toBeUndefined();
});
it("should filter by organization when provided", async () => {
const session1: Cookie = {
id: "session-1",
@@ -863,31 +897,14 @@ describe("cookies", () => {
expect(result.id).toBe("session-2");
});
it("should reject when no matching session found", async () => {
const session: Cookie = {
id: "session-1",
token: "token-1",
loginName: "[email protected]",
creationTs: "1700000000000",
expirationTs: "1800000000000",
changeTs: "1700000000000",
};
mockCookies.get.mockReturnValue({
value: JSON.stringify([session]),
});
await expect(getMostRecentCookieWithLoginname({ loginName: "[email protected]" })).rejects.toBe(
"Could not get the context or retrieve a session",
);
});
it("should reject when no session cookie exists", async () => {
it("should return undefined when no session cookie exists", async () => {
mockCookies.get.mockReturnValue(undefined);
await expect(getMostRecentCookieWithLoginname({ loginName: "[email protected]" })).rejects.toBe(
"Could not read session cookie",
);
await expect(
getMostRecentCookieWithLoginname({
loginName: "[email protected]",
}),
).resolves.toBeUndefined();
});
});
});
+61 -70
View File
@@ -157,7 +157,7 @@ export async function removeSessionFromCookie<T>({
}
}
export async function getMostRecentSessionCookie<T>(): Promise<Cookie> {
export async function getMostRecentSessionCookie<T>(): Promise<Cookie | undefined> {
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");
@@ -170,7 +170,7 @@ export async function getMostRecentSessionCookie<T>(): Promise<Cookie> {
return latest;
} else {
return Promise.reject("no session cookie found");
return undefined;
}
}
@@ -180,7 +180,7 @@ export async function getSessionCookieById<T>({
}: {
sessionId: string;
organization?: string;
}): Promise<SessionCookie<T>> {
}): Promise<SessionCookie<T> | undefined> {
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");
@@ -193,10 +193,10 @@ export async function getSessionCookieById<T>({
if (found) {
return found;
} else {
return Promise.reject();
return undefined;
}
} else {
return Promise.reject();
return undefined;
}
}
@@ -206,23 +206,18 @@ export async function getSessionCookieByLoginName<T>({
}: {
loginName?: string;
organization?: string;
}): Promise<SessionCookie<T>> {
}): Promise<SessionCookie<T> | undefined> {
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) {
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie?.value);
const found = sessions.find((s) =>
organization ? s.organization === organization && s.loginName === loginName : s.loginName === loginName,
);
if (found) {
return found;
} else {
return Promise.reject("no cookie found with loginName: " + loginName);
}
} else {
return Promise.reject("no session cookie found");
if (!stringifiedCookie?.value) {
return undefined;
}
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie.value);
return sessions.find((s) =>
organization ? s.organization === organization && s.loginName === loginName : s.loginName === loginName,
);
}
/**
@@ -234,22 +229,22 @@ export async function getAllSessionCookieIds<T>(cleanup: boolean = false): Promi
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) {
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie?.value);
if (cleanup) {
const now = new Date();
return sessions
.filter((session) =>
session.expirationTs ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true,
)
.map((session) => session.id);
} else {
return sessions.map((session) => session.id);
}
} else {
if (!stringifiedCookie?.value) {
return [];
}
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie.value);
if (cleanup) {
const now = new Date();
return sessions
.filter((session) =>
session.expirationTs ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true,
)
.map((session) => session.id);
}
return sessions.map((session) => session.id);
}
/**
@@ -261,21 +256,21 @@ export async function getAllSessions<T>(cleanup: boolean = false): Promise<Sessi
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) {
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie?.value);
if (cleanup) {
const now = new Date();
return sessions.filter((session) =>
session.expirationTs ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true,
);
} else {
return sessions;
}
} else {
if (!stringifiedCookie?.value) {
console.log("getAllSessions: No session cookie found, returning empty array");
return [];
}
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie.value);
if (cleanup) {
const now = new Date();
return sessions.filter((session) =>
session.expirationTs ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true,
);
}
return sessions;
}
/**
@@ -294,31 +289,27 @@ export async function getMostRecentCookieWithLoginname<T>({
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) {
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie?.value);
let filtered = sessions.filter((cookie) => {
return loginName ? cookie.loginName === loginName : true;
});
if (organization) {
filtered = filtered.filter((cookie) => {
return cookie.organization === organization;
});
}
const latest =
filtered && filtered.length
? filtered.reduce((prev, current) => {
return prev.changeTs > current.changeTs ? prev : current;
})
: undefined;
if (latest) {
return latest;
} else {
return Promise.reject("Could not get the context or retrieve a session");
}
} else {
return Promise.reject("Could not read session cookie");
if (!stringifiedCookie?.value) {
return undefined;
}
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie.value);
let filtered = sessions;
if (loginName) {
filtered = filtered.filter((cookie) => cookie.loginName === loginName);
}
if (organization) {
filtered = filtered.filter((cookie) => cookie.organization === organization);
}
if (!filtered || !filtered.length) {
return undefined;
}
return filtered.reduce((prev, current) => {
return prev.changeTs > current.changeTs ? prev : current;
});
}
+6 -10
View File
@@ -11,21 +11,17 @@ const myUserService = async (serviceConfig: ServiceConfig, sessionToken: string)
return createUserServiceClient(transportPromise);
};
export async function setMyPassword({
sessionId,
password,
}: {
sessionId: string;
password: string;
}) {
export async function setMyPassword({ sessionId, password }: { sessionId: string; password: string }) {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const sessionCookie = await getSessionCookieById({ sessionId });
const { session } = await getSession({ serviceConfig, sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
});
if (!sessionCookie) {
return { error: "Could not load session cookie" };
}
const { session } = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token });
if (!session) {
return { error: "Could not load session" };
+50 -38
View File
@@ -3,7 +3,7 @@
import { addSessionToCookie, updateSessionCookie } from "@/lib/cookies";
import {
createSessionForUserIdAndIdpIntent,
createSessionFromChecks,
createSessionFromChecksAndChallenges,
getSecuritySettings,
getSession,
setSession,
@@ -44,7 +44,8 @@ export async function createSessionAndUpdateCookie(command: {
checks: Checks;
requestId: string | undefined;
lifetime?: Duration;
}): Promise<Session> {
challenges?: RequestChallenges;
}): Promise<{ session: Session; sessionCookie: CustomCookieData; challenges?: Challenges }> {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
@@ -59,12 +60,17 @@ export async function createSessionAndUpdateCookie(command: {
} as Duration; // for usecases where the lifetime is not specified (user discovery)
}
const createdSession = await createSessionFromChecks({ serviceConfig, checks: command.checks,
const createdSession = await createSessionFromChecksAndChallenges({
serviceConfig,
checks: command.checks,
lifetime: sessionLifetime,
challenges: command.challenges,
});
if (createdSession) {
return getSession({ serviceConfig, sessionId: createdSession.sessionId,
return getSession({
serviceConfig,
sessionId: createdSession.sessionId,
sessionToken: createdSession.sessionToken,
}).then(async (response) => {
if (response?.session && response.session?.factors?.user?.loginName) {
@@ -90,7 +96,7 @@ export async function createSessionAndUpdateCookie(command: {
await addSessionToCookie({ session: sessionCookie, iFrameEnabled });
return response.session as Session;
return { session: response.session as Session, sessionCookie, challenges: createdSession.challenges };
} else {
throw "could not get session or session does not have loginName";
}
@@ -128,7 +134,9 @@ export async function createSessionForIdpAndUpdateCookie({
} as Duration;
}
const createdSession = await createSessionForUserIdAndIdpIntent({ serviceConfig, userId,
const createdSession = await createSessionForUserIdAndIdpIntent({
serviceConfig,
userId,
idpIntent,
lifetime: sessionLifetime,
}).catch((error: ErrorDetail | CredentialsCheckError) => {
@@ -146,7 +154,9 @@ export async function createSessionForIdpAndUpdateCookie({
throw "Could not create session";
}
const { session } = await getSession({ serviceConfig, sessionId: createdSession.sessionId,
const { session } = await getSession({
serviceConfig,
sessionId: createdSession.sessionId,
sessionToken: createdSession.sessionToken,
});
@@ -194,7 +204,9 @@ export async function setSessionAndUpdateCookie(command: {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
return setSession({ serviceConfig, sessionId: command.recentCookie.id,
return setSession({
serviceConfig,
sessionId: command.recentCookie.id,
sessionToken: command.recentCookie.token,
challenges: command.challenges,
checks: command.checks,
@@ -217,40 +229,40 @@ export async function setSessionAndUpdateCookie(command: {
sessionCookie.requestId = command.requestId;
}
return getSession({ serviceConfig, sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
}).then(async (response) => {
if (!response?.session || !response.session.factors?.user?.loginName) {
throw "could not get session or session does not have loginName";
}
return getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token }).then(
async (response) => {
if (!response?.session || !response.session.factors?.user?.loginName) {
throw "could not get session or session does not have loginName";
}
const { session } = response;
const newCookie: CustomCookieData = {
id: sessionCookie.id,
token: updatedSession.sessionToken,
creationTs: sessionCookie.creationTs,
expirationTs: sessionCookie.expirationTs,
// just overwrite the changeDate with the new one
changeTs: updatedSession.details?.changeDate ? `${timestampMs(updatedSession.details.changeDate)}` : "",
loginName: session.factors?.user?.loginName ?? "",
organization: session.factors?.user?.organizationId ?? "",
};
const { session } = response;
const newCookie: CustomCookieData = {
id: sessionCookie.id,
token: updatedSession.sessionToken,
creationTs: sessionCookie.creationTs,
expirationTs: sessionCookie.expirationTs,
// just overwrite the changeDate with the new one
changeTs: updatedSession.details?.changeDate ? `${timestampMs(updatedSession.details.changeDate)}` : "",
loginName: session.factors?.user?.loginName ?? "",
organization: session.factors?.user?.organizationId ?? "",
};
if (sessionCookie.requestId) {
newCookie.requestId = sessionCookie.requestId;
}
if (sessionCookie.requestId) {
newCookie.requestId = sessionCookie.requestId;
}
const securitySettings = await getSecuritySettings({ serviceConfig });
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
const securitySettings = await getSecuritySettings({ serviceConfig });
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
return updateSessionCookie({
id: sessionCookie.id,
session: newCookie,
iFrameEnabled,
}).then(() => {
return { challenges: updatedSession.challenges, ...session };
});
});
return updateSessionCookie({
id: sessionCookie.id,
session: newCookie,
iFrameEnabled,
}).then(() => {
return { challenges: updatedSession.challenges, ...session };
});
},
);
} else {
throw "Session not be set";
}
+432 -9
View File
@@ -195,7 +195,7 @@ describe("sendLoginname", () => {
mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true });
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockCreate.mockReturnValue({});
mockCreateSessionAndUpdateCookie.mockResolvedValue(mockSession);
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
});
test("should redirect to verify when user has no authentication methods", async () => {
@@ -339,6 +339,27 @@ describe("sendLoginname", () => {
expect(result).toEqual({ redirect: "https://idp.example.com/auth" });
});
test("should NOT create session when ignoreUnknownUsernames is true", async () => {
mockGetLoginSettings.mockResolvedValue({
allowUsernamePassword: true,
ignoreUnknownUsernames: true,
});
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
const result = await sendLoginname({
loginName: "[email protected]",
requestId: "req123",
});
expect(mockCreateSessionAndUpdateCookie).not.toHaveBeenCalled();
expect(result).toHaveProperty("redirect");
expect((result as any).redirect).toMatch(/^\/password\?/);
expect((result as any).redirect).toContain("loginName=user%40example.com");
});
});
describe("Multiple authentication methods", () => {
@@ -470,6 +491,7 @@ describe("sendLoginname", () => {
loginName: "[email protected]",
requestId: "req123",
organization: "org123",
ignoreUnknownUsernames: true,
});
expect(result).toBeDefined();
@@ -646,10 +668,128 @@ describe("sendLoginname", () => {
// Verify org discovery was NOT called since org was provided
expect(mockGetOrgsByDomain).not.toHaveBeenCalled();
});
});
describe("Edge cases", () => {
test("should handle session creation failure", async () => {
test("should redirect to password when ignoreUnknownUsernames is true, allowRegister is true, but allowUsernamePassword is false (User not found)", async () => {
mockSearchUsers.mockResolvedValue({ result: [] });
mockGetLoginSettings.mockResolvedValue({
allowRegister: true,
allowUsernamePassword: false,
ignoreUnknownUsernames: true,
});
mockGetActiveIdentityProviders.mockResolvedValue({ identityProviders: [] });
const result = await sendLoginname({
loginName: "[email protected]",
ignoreUnknownUsernames: true,
});
expect(result).not.toEqual({ error: "errors.userNotFound" });
expect(result).toHaveProperty("redirect");
expect((result as any).redirect).toMatch(/^\/password\?/);
});
test("should redirect to password when ignoreUnknownUsernames is true, user found but rejected due to disableLoginWithEmail", async () => {
const mockUser = {
userId: "user123",
preferredLoginName: "user",
details: { resourceOwner: "org123" },
type: { case: "human", value: { email: { email: "[email protected]" }, phone: { phone: "123456" } } },
state: UserState.ACTIVE,
};
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockGetLoginSettings.mockResolvedValue({
disableLoginWithEmail: true,
ignoreUnknownUsernames: true,
});
const result = await sendLoginname({
loginName: "[email protected]",
ignoreUnknownUsernames: true,
});
expect(result).not.toEqual({ error: "errors.userNotFound" });
expect(result).toHaveProperty("redirect");
expect((result as any).redirect).toMatch(/^\/password\?/);
});
test("should redirect to password when ignoreUnknownUsernames is true and more than one user found", async () => {
mockGetLoginSettings.mockResolvedValue({
ignoreUnknownUsernames: true,
});
mockSearchUsers.mockResolvedValue({
result: [
{ userId: "user1", preferredLoginName: "[email protected]" },
{ userId: "user2", preferredLoginName: "[email protected]" },
],
});
const result = await sendLoginname({
loginName: "[email protected]",
ignoreUnknownUsernames: true,
});
expect(result).not.toEqual({ error: "errors.moreThanOneUserFound" });
expect(result).toHaveProperty("redirect");
expect((result as any).redirect).toMatch(/^\/password\?/);
});
test("should return generic error when user not active and ignoreUnknownUsernames is true", async () => {
mockSearchUsers.mockResolvedValue({
result: [
{
userId: "user1",
state: UserState.ACTIVE,
preferredLoginName: "user1",
type: { case: "human", value: { email: { isVerified: true } } },
},
],
});
mockGetLoginSettings.mockResolvedValue({
ignoreUnknownUsernames: true,
allowUsernamePassword: true,
});
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
// Mock createSessionAndUpdateCookie to fail with user not active error
mockCreateSessionAndUpdateCookie.mockRejectedValue({
rawMessage: "Errors.User.NotActive (SESSION-Gj4ko)",
});
const result = await sendLoginname({ loginName: "user1", ignoreUnknownUsernames: true });
expect(result).toEqual({ redirect: "/password?loginName=user1" });
// With ignoreUnknownUsernames: true, we skip session creation, so this mock is NOT called
expect(mockCreateSessionAndUpdateCookie).not.toHaveBeenCalled();
});
test("should NOT create session and return redirect when ignoreUnknownUsernames is true and user is valid", async () => {
mockSearchUsers.mockResolvedValue({
result: [
{
userId: "user1",
state: UserState.ACTIVE,
preferredLoginName: "user1",
type: { case: "human", value: { email: { isVerified: true } } },
},
],
});
mockGetLoginSettings.mockResolvedValue({
ignoreUnknownUsernames: true,
allowUsernamePassword: true,
});
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
const result = await sendLoginname({ loginName: "user1", ignoreUnknownUsernames: true });
expect(result).toEqual({ redirect: "/password?loginName=user1" });
expect(mockCreateSessionAndUpdateCookie).not.toHaveBeenCalled();
});
test("should redirect to password when ignoreUnknownUsernames is true and password not allowed", async () => {
const mockUser = {
userId: "user123",
preferredLoginName: "[email protected]",
@@ -658,18 +798,81 @@ describe("sendLoginname", () => {
state: UserState.ACTIVE,
};
mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true });
const mockSession = {
factors: {
user: {
id: "user123",
loginName: "[email protected]",
organizationId: "org123",
},
},
};
mockGetLoginSettings.mockResolvedValue({
allowUsernamePassword: false,
ignoreUnknownUsernames: true,
});
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockCreate.mockReturnValue({});
mockCreateSessionAndUpdateCookie.mockResolvedValue({ factors: {} }); // No user in session
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
mockListIDPLinks.mockResolvedValue({ result: [] });
mockGetActiveIdentityProviders.mockResolvedValue({ identityProviders: [] });
const result = await sendLoginname({
loginName: "[email protected]",
ignoreUnknownUsernames: true,
});
expect(result).toEqual({ error: "errors.couldNotCreateSession" });
expect(result).not.toEqual({ error: "errors.usernamePasswordNotAllowed" });
expect(result).toHaveProperty("redirect");
expect((result as any).redirect).toMatch(/^\/password\?/);
});
test("should redirect to password when ignoreUnknownUsernames is true and passkeys not allowed", async () => {
const mockUser = {
userId: "user123",
preferredLoginName: "[email protected]",
details: { resourceOwner: "org123" },
type: { case: "human", value: { email: { email: "[email protected]" } } },
state: UserState.ACTIVE,
};
const mockSession = {
factors: {
user: {
id: "user123",
loginName: "[email protected]",
organizationId: "org123",
},
},
};
mockGetLoginSettings.mockResolvedValue({
passkeysType: PasskeysType.NOT_ALLOWED,
ignoreUnknownUsernames: true,
});
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockCreate.mockReturnValue({});
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSKEY],
});
const result = await sendLoginname({
loginName: "[email protected]",
ignoreUnknownUsernames: true,
});
expect(result).not.toEqual({ error: "errors.passkeysNotAllowed" });
expect(result).toHaveProperty("redirect");
expect((result as any).redirect).toMatch(/^\/password\?/);
});
});
describe("Edge cases", () => {
test("should handle initial user state", async () => {
const mockUser = {
userId: "user123",
@@ -692,7 +895,7 @@ describe("sendLoginname", () => {
mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true });
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockCreate.mockReturnValue({});
mockCreateSessionAndUpdateCookie.mockResolvedValue(mockSession);
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
const result = await sendLoginname({
loginName: "[email protected]",
@@ -723,7 +926,7 @@ describe("sendLoginname", () => {
mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true });
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockCreate.mockReturnValue({});
mockCreateSessionAndUpdateCookie.mockResolvedValue(mockSession);
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
@@ -738,5 +941,225 @@ describe("sendLoginname", () => {
expect(result?.redirect).toContain("organization=custom-org");
expect(result?.redirect).toContain("requestId=req123");
});
test("should redirect to password with INPUT loginName when ignoreUnknownUsernames is true, even if user preferredLoginName is different", async () => {
const mockUser = {
userId: "user123",
preferredLoginName: "[email protected]",
details: { resourceOwner: "org123" },
type: { case: "human", value: { email: { email: "[email protected]" } } },
state: UserState.ACTIVE,
};
const mockSession = {
factors: {
user: {
id: "user123",
loginName: "[email protected]",
organizationId: "org123",
},
},
};
mockGetLoginSettings.mockResolvedValue({
allowUsernamePassword: true,
ignoreUnknownUsernames: true,
});
// Mock search result returns a user with resolved/different loginName
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockCreate.mockReturnValue({});
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
// INPUT login name is just "user"
const result = await sendLoginname({
loginName: "user",
ignoreUnknownUsernames: true,
});
expect(result).toHaveProperty("redirect");
// Expect redirect to contain input "user" not resolved "[email protected]"
expect((result as any).redirect).toContain("loginName=user");
expect((result as any).redirect).not.toContain("loginName=user%40example.com");
});
test("should redirect to passkey with INPUT loginName when ignoreUnknownUsernames is true", async () => {
const mockUser = {
userId: "user123",
preferredLoginName: "[email protected]",
details: { resourceOwner: "org123" },
type: { case: "human", value: { email: { email: "[email protected]" } } },
state: UserState.ACTIVE,
};
const mockSession = {
factors: {
user: {
id: "user123",
loginName: "[email protected]",
organizationId: "org123",
},
},
};
mockGetLoginSettings.mockResolvedValue({
passkeysType: PasskeysType.ALLOWED,
ignoreUnknownUsernames: true,
});
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockCreate.mockReturnValue({});
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSKEY],
});
const result = await sendLoginname({
loginName: "user",
ignoreUnknownUsernames: true,
});
expect(result).toHaveProperty("redirect");
expect((result as any).redirect).toMatch(/^\/passkey\?/);
expect((result as any).redirect).toContain("loginName=user");
expect((result as any).redirect).not.toContain("loginName=user%40example.com");
});
test("should redirect to passkey with INPUT loginName when ignoreUnknownUsernames is true (multi-method)", async () => {
const mockUser = {
userId: "user123",
preferredLoginName: "[email protected]",
details: { resourceOwner: "org123" },
type: { case: "human", value: { email: { email: "[email protected]" } } },
state: UserState.ACTIVE,
};
const mockSession = {
factors: {
user: {
id: "user123",
loginName: "[email protected]",
organizationId: "org123",
},
},
};
mockGetLoginSettings.mockResolvedValue({
passkeysType: PasskeysType.ALLOWED,
ignoreUnknownUsernames: true,
});
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockCreate.mockReturnValue({});
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSKEY, AuthenticationMethodType.PASSWORD],
});
const result = await sendLoginname({
loginName: "user",
ignoreUnknownUsernames: true,
});
expect(result).toHaveProperty("redirect");
expect((result as any).redirect).toMatch(/^\/passkey\?/);
expect((result as any).redirect).toContain("loginName=user");
expect((result as any).redirect).not.toContain("loginName=user%40example.com");
});
test("should use CONTEXT settings to HIDE username even if USER settings would show it", async () => {
const mockUser = {
userId: "user123",
preferredLoginName: "[email protected]",
details: { resourceOwner: "user-org" },
type: { case: "human", value: { email: { email: "[email protected]" } } },
state: UserState.ACTIVE,
};
const mockSession = {
factors: {
user: {
id: "user123",
loginName: "[email protected]",
organizationId: "user-org",
},
},
};
// Mock implementation to return different settings based on organization
mockGetLoginSettings.mockImplementation(async (args: any) => {
if (args.organization === "context-org") {
return { allowUsernamePassword: true, ignoreUnknownUsernames: true };
}
if (args.organization === "user-org") {
return { allowUsernamePassword: true, ignoreUnknownUsernames: false };
}
return {};
});
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockCreate.mockReturnValue({});
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
const result = await sendLoginname({
loginName: "input-name",
organization: "context-org", // Context has ignore=true
ignoreUnknownUsernames: true,
});
expect(result).toHaveProperty("redirect");
// Should result in input-name because context says HIDE
expect((result as any).redirect).toContain("loginName=input-name");
expect((result as any).redirect).not.toContain("loginName=user%40example.com");
});
test("should use CONTEXT settings to SHOW username even if USER settings would hide it", async () => {
const mockUser = {
userId: "user123",
preferredLoginName: "[email protected]",
details: { resourceOwner: "user-org" },
type: { case: "human", value: { email: { email: "[email protected]" } } },
state: UserState.ACTIVE,
};
const mockSession = {
factors: {
user: {
id: "user123",
loginName: "[email protected]",
organizationId: "user-org",
},
},
};
mockGetLoginSettings.mockImplementation(async (args: any) => {
if (args.organization === "context-org") {
return { allowUsernamePassword: true, ignoreUnknownUsernames: false };
}
if (args.organization === "user-org") {
return { allowUsernamePassword: true, ignoreUnknownUsernames: true };
}
return {};
});
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockCreate.mockReturnValue({});
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
const result = await sendLoginname({
loginName: "input-name",
organization: "context-org", // Context has ignore=false
ignoreUnknownUsernames: false,
});
expect(result).toHaveProperty("redirect");
// Should result in resolved name because context says SHOW
expect((result as any).redirect).toContain("loginName=user%40example.com");
});
});
});
+108 -49
View File
@@ -8,6 +8,7 @@ import { headers } from "next/headers";
import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp";
import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { IDPLink } from "@zitadel/proto/zitadel/user/v2/idp_pb";
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { getServiceConfig } from "../service-url";
import {
@@ -23,13 +24,14 @@ import {
} from "../zitadel";
import { createSessionAndUpdateCookie } from "./cookie";
import { getPublicHost } from "./host";
import { IDPLink } from "@zitadel/proto/zitadel/user/v2/idp_pb";
export type SendLoginnameCommand = {
loginName: string;
requestId?: string;
organization?: string;
defaultOrganization?: string;
suffix?: string;
ignoreUnknownUsernames?: boolean;
};
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
@@ -81,6 +83,26 @@ export async function sendLoginname(command: SendLoginnameCommand) {
console.log("No users found, will proceed with org discovery");
}
const preventUserEnumeration = (organization: string | undefined) => {
if (command.ignoreUnknownUsernames) {
console.log("ignoreUnknownUsernames is true, redirecting to password");
const paramsPasswordDefault = new URLSearchParams({
loginName: command.loginName,
});
if (command.requestId) {
paramsPasswordDefault.append("requestId", command.requestId);
}
if (organization) {
paramsPasswordDefault.append("organization", organization);
}
return { redirect: "/password?" + paramsPasswordDefault };
}
return { error: t("errors.userNotFound") };
};
const redirectUserToIDP = async (userId?: string, organization?: string) => {
// If userId is provided, check for user-specific IDP links first
let identityProviders: IDPLink[] = [];
@@ -199,6 +221,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (users.length > 1) {
console.log("multiple users found, returning error");
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return preventUserEnumeration(command.organization);
}
return { error: t("errors.moreThanOneUserFound") };
} else if (users.length == 1 && users[0].userId) {
const user = users[0];
@@ -214,56 +239,84 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// recheck login settings after user discovery, as the search might have been done without org scope
if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== concatLoginname) {
return { error: t("errors.userNotFound") };
return preventUserEnumeration(command.organization);
}
} else if (userLoginSettings?.disableLoginWithEmail) {
if (user.preferredLoginName !== concatLoginname || humanUser?.phone?.phone !== command.loginName) {
return { error: t("errors.userNotFound") };
return preventUserEnumeration(command.organization);
}
} else if (userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== concatLoginname || humanUser?.email?.email !== command.loginName) {
return { error: t("errors.userNotFound") };
return preventUserEnumeration(command.organization);
}
}
const checks = create(ChecksSchema, {
user: { search: { case: "userId", value: userId } },
});
let session;
if (!userLoginSettings?.ignoreUnknownUsernames) {
const checks = create(ChecksSchema, {
user: { search: { case: "userId", value: userId } },
});
const sessionOrError = await createSessionAndUpdateCookie({
checks,
requestId: command.requestId,
}).catch((error) => {
if (error?.rawMessage === "Errors.User.NotActive (SESSION-Gj4ko)") {
return { error: t("errors.userNotActive") };
const sessionOrError = await createSessionAndUpdateCookie({
checks,
requestId: command.requestId,
}).catch((error) => {
if (error?.rawMessage === "Errors.User.NotActive (SESSION-Gj4ko)") {
return { error: t("errors.userNotActive") };
}
throw error;
});
if ("error" in sessionOrError) {
return sessionOrError;
}
throw error;
});
if ("error" in sessionOrError) {
return sessionOrError;
session = sessionOrError.session;
}
const session = sessionOrError;
if (!session.factors?.user?.id) {
if (session && !session.factors?.user?.id) {
return { error: t("errors.couldNotCreateSession") };
}
// TODO: check if handling of userstate INITIAL is needed
if (user.state === UserState.INITIAL) {
if (userLoginSettings?.ignoreUnknownUsernames) {
return preventUserEnumeration(command.organization);
}
return { error: t("errors.initialUserNotSupported") };
}
// Resolve organization from command or session
const organization = command.organization ?? session.factors?.user?.organizationId;
let organization = command.organization ?? session?.factors?.user?.organizationId ?? user.details?.resourceOwner;
const methods = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors?.user?.id });
if (userLoginSettings?.ignoreUnknownUsernames) {
organization = command.organization;
if (!organization && ORG_SUFFIX_REGEX.test(command.loginName)) {
const matched = ORG_SUFFIX_REGEX.exec(command.loginName);
const suffix = matched?.[1] ?? "";
const orgs = await getOrgsByDomain({ serviceConfig, domain: suffix });
if (orgs.result && orgs.result.length === 1) {
const orgToCheckForDiscovery = orgs.result[0].id;
const orgLoginSettings = await getLoginSettings({ serviceConfig, organization: orgToCheckForDiscovery });
if (orgLoginSettings?.allowDomainDiscovery) {
organization = orgToCheckForDiscovery;
}
}
}
}
const methods = await listAuthenticationMethodTypes({
serviceConfig,
userId: session?.factors?.user?.id ?? userId,
});
// always resend invite if user has no auth method set
if (!methods.authMethodTypes || !methods.authMethodTypes.length) {
console.log("humanUser.email?.isVerified", humanUser?.email?.isVerified);
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
loginName: (session?.factors?.user?.loginName ?? user.preferredLoginName) as string,
send: "true", // set this to true to request a new code immediately
invite: humanUser?.email?.isVerified ? "false" : "true", // sendInviteEmailCode results in an error if user is already initialized
});
@@ -290,17 +343,21 @@ export async function sendLoginname(command: SendLoginnameCommand) {
return idpResp;
}
if (command.ignoreUnknownUsernames) {
return preventUserEnumeration(command.organization);
}
return {
error: t("errors.usernamePasswordNotAllowed"),
};
}
const paramsPassword = new URLSearchParams({
loginName: session.factors?.user?.loginName,
loginName: command.ignoreUnknownUsernames
? command.loginName
: (session?.factors?.user?.loginName ?? user.preferredLoginName),
});
// TODO: does this have to be checked in loginSettings.allowDomainDiscovery
if (organization) {
paramsPassword.append("organization", organization);
}
@@ -315,13 +372,18 @@ export async function sendLoginname(command: SendLoginnameCommand) {
case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
if (userLoginSettings?.passkeysType === PasskeysType.NOT_ALLOWED) {
if (command.ignoreUnknownUsernames) {
return preventUserEnumeration(command.organization);
}
return {
error: t("errors.passkeysNotAllowed"),
};
}
const paramsPasskey = new URLSearchParams({
loginName: session.factors?.user?.loginName,
loginName: command.ignoreUnknownUsernames
? command.loginName
: (session?.factors?.user?.loginName ?? user.preferredLoginName),
});
if (command.requestId) {
paramsPasskey.append("requestId", command.requestId);
@@ -346,7 +408,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// prefer passkey in favor of other methods
if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSKEY)) {
const passkeyParams = new URLSearchParams({
loginName: session.factors?.user?.loginName,
loginName: command.ignoreUnknownUsernames
? command.loginName
: (session?.factors?.user?.loginName ?? user.preferredLoginName),
altPassword: `${methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD) && userLoginSettings?.allowUsernamePassword}`, // show alternative password option only if allowed
});
@@ -364,6 +428,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
} else if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)) {
// Check if password authentication is allowed
if (!userLoginSettings?.allowUsernamePassword) {
if (command.ignoreUnknownUsernames) {
return preventUserEnumeration(command.organization);
}
return {
error: "Username Password not allowed! Contact your administrator for more information.",
};
@@ -371,7 +438,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// user has no passkey setup and login settings allow passwords
const paramsPasswordDefault = new URLSearchParams({
loginName: session.factors?.user?.loginName,
loginName: command.ignoreUnknownUsernames
? command.loginName
: (session?.factors?.user?.loginName ?? user.preferredLoginName),
});
if (command.requestId) {
@@ -395,6 +464,13 @@ export async function sendLoginname(command: SendLoginnameCommand) {
let discoveredOrganization = command.organization;
let effectiveLoginSettings = loginSettingsByContext;
if (!command.organization && command.defaultOrganization) {
const defaultLoginSettings = await getLoginSettings({ serviceConfig, organization: command.defaultOrganization });
if (defaultLoginSettings) {
effectiveLoginSettings = defaultLoginSettings;
}
}
if (!discoveredOrganization && command.loginName && ORG_SUFFIX_REGEX.test(command.loginName)) {
const matched = ORG_SUFFIX_REGEX.exec(command.loginName);
const suffix = matched?.[1] ?? "";
@@ -428,7 +504,8 @@ export async function sendLoginname(command: SendLoginnameCommand) {
return resp;
}
console.log("IDP redirect failed, returning user not found");
return { error: t("errors.userNotFound") };
return preventUserEnumeration(discoveredOrganization);
} else if (effectiveLoginSettings?.allowRegister && effectiveLoginSettings?.allowUsernamePassword) {
console.log("register and password both allowed");
// do not register user if ignoreUnknownUsernames is set
@@ -453,23 +530,5 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}
}
if (effectiveLoginSettings?.ignoreUnknownUsernames) {
console.log("ignoreUnknownUsernames is true, redirecting to password");
const paramsPasswordDefault = new URLSearchParams({
loginName: command.loginName,
});
if (command.requestId) {
paramsPasswordDefault.append("requestId", command.requestId);
}
if (discoveredOrganization) {
paramsPasswordDefault.append("organization", discoveredOrganization);
}
return { redirect: "/password?" + paramsPasswordDefault };
}
console.log("no valid registration option found, returning user not found");
return { error: t("errors.userNotFound") };
return preventUserEnumeration(discoveredOrganization);
}
+72 -9
View File
@@ -20,10 +20,12 @@ vi.mock("../service-url", () => ({
vi.mock("../zitadel", () => ({
getLoginSettings: vi.fn(),
getUserByID: vi.fn(),
listUsers: vi.fn(),
}));
vi.mock("./cookie", () => ({
setSessionAndUpdateCookie: vi.fn(),
createSessionAndUpdateCookie: vi.fn(),
}));
vi.mock("../cookies", () => ({
@@ -50,7 +52,9 @@ describe("sendPasskey", () => {
let mockGetServiceUrlFromHeaders: any;
let mockGetLoginSettings: any;
let mockGetUserByID: any;
let mockListUsers: any;
let mockSetSessionAndUpdateCookie: any;
let mockCreateSessionAndUpdateCookie: any;
let mockGetSessionCookieById: any;
let mockGetSessionCookieByLoginName: any;
let mockGetMostRecentSessionCookie: any;
@@ -63,8 +67,8 @@ describe("sendPasskey", () => {
// Import mocked modules
const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url");
const { getLoginSettings, getUserByID } = await import("../zitadel");
const { setSessionAndUpdateCookie } = await import("./cookie");
const { getLoginSettings, getUserByID, listUsers } = await import("../zitadel");
const { setSessionAndUpdateCookie, createSessionAndUpdateCookie } = await import("./cookie");
const { getSessionCookieById, getSessionCookieByLoginName, getMostRecentSessionCookie } = await import("../cookies");
const { checkEmailVerification } = await import("../verify-helper");
const { completeFlowOrGetUrl } = await import("../client");
@@ -74,7 +78,13 @@ describe("sendPasskey", () => {
mockGetServiceUrlFromHeaders = vi.mocked(getServiceConfig);
mockGetLoginSettings = vi.mocked(getLoginSettings);
mockGetUserByID = vi.mocked(getUserByID);
mockListUsers = vi.mocked(listUsers);
mockSetSessionAndUpdateCookie = vi.mocked(setSessionAndUpdateCookie);
mockCreateSessionAndUpdateCookie = vi.mocked(createSessionAndUpdateCookie);
mockCreateSessionAndUpdateCookie.mockResolvedValue({
session: { id: "new-session", factors: { user: { id: "user-1", loginName: "user" } } } as any,
sessionCookie: { id: "new-session", token: "token", loginName: "user" },
});
mockGetSessionCookieById = vi.mocked(getSessionCookieById);
mockGetSessionCookieByLoginName = vi.mocked(getSessionCookieByLoginName);
mockGetMostRecentSessionCookie = vi.mocked(getMostRecentSessionCookie);
@@ -82,7 +92,9 @@ describe("sendPasskey", () => {
mockCompleteFlowOrGetUrl = vi.mocked(completeFlowOrGetUrl);
// Default mock implementations
mockHeaders.mockResolvedValue(new Headers());
const headersList = new Headers();
headersList.set("host", "test.com");
mockHeaders.mockResolvedValue(headersList);
mockGetServiceUrlFromHeaders.mockReturnValue({
serviceUrl: "https://example.com",
});
@@ -104,7 +116,7 @@ describe("sendPasskey", () => {
});
expect(result).toEqual({
error: "verify.errors.couldNotFindSession",
error: "couldNotFindSession",
});
expect(mockGetSessionCookieById).toHaveBeenCalledWith({
sessionId: "test-session-id",
@@ -112,7 +124,8 @@ describe("sendPasskey", () => {
});
test("should return error when session cookie is not found by loginName", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue(null);
mockGetSessionCookieByLoginName.mockResolvedValue(null); // Not found
mockCreateSessionAndUpdateCookie.mockRejectedValue(new Error("Creation failed")); // Force creation failure
const result = await sendPasskey({
loginName: "[email protected]",
@@ -121,7 +134,7 @@ describe("sendPasskey", () => {
});
expect(result).toEqual({
error: "verify.errors.couldNotFindSession",
error: "couldNotFindSession",
});
expect(mockGetSessionCookieByLoginName).toHaveBeenCalledWith({
loginName: "[email protected]",
@@ -137,7 +150,7 @@ describe("sendPasskey", () => {
});
expect(result).toEqual({
error: "verify.errors.couldNotFindSession",
error: "couldNotFindSession",
});
expect(mockGetMostRecentSessionCookie).toHaveBeenCalled();
});
@@ -163,7 +176,7 @@ describe("sendPasskey", () => {
});
expect(result).toEqual({
error: "verify.errors.couldNotUpdateSession",
error: "couldNotUpdateSession",
});
});
@@ -176,9 +189,59 @@ describe("sendPasskey", () => {
});
expect(result).toEqual({
error: "verify.errors.couldNotUpdateSession",
error: "couldNotUpdateSession",
});
});
test("should fallback to createSessionAndUpdateCookie when setSessionAndUpdateCookie fails and checks are present", async () => {
mockSetSessionAndUpdateCookie.mockRejectedValue(new Error("session already terminated"));
mockCreateSessionAndUpdateCookie.mockResolvedValue({
session: {
id: "new-session-123",
factors: {
user: {
id: "user-123",
loginName: "[email protected]",
},
},
},
sessionCookie: {
id: "new-session-123",
token: "new-token",
},
});
mockListUsers.mockResolvedValue({
details: { totalResult: BigInt(1) },
result: [{ userId: "user-123" }],
});
mockGetUserByID.mockResolvedValue({
user: {
id: "user-123",
type: {
case: "human",
value: { email: { isVerified: true } },
},
},
});
mockCheckEmailVerification.mockResolvedValue(true);
mockCompleteFlowOrGetUrl.mockResolvedValue({ redirect: "/dashboard" });
const result = await sendPasskey({
sessionId: "session-123",
checks: { webAuthN: { credentialAssertionData: {} } } as any,
});
// It should succeed with the new session
expect(result).toEqual({
redirect: "/dashboard",
});
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
});
});
describe("User Validation", () => {
+40 -43
View File
@@ -19,12 +19,13 @@ import {
import { headers } from "next/headers";
import { userAgent } from "next/server";
import { getTranslations } from "next-intl/server";
import { getMostRecentSessionCookie, getSessionCookieById, getSessionCookieByLoginName } from "../cookies";
import { getSessionCookieById } from "../cookies";
import { getServiceConfig } from "../service-url";
import { checkEmailVerification, checkUserVerification } from "../verify-helper";
import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "./cookie";
import { getPublicHost } from "./host";
import { updateOrCreateSession } from "./session";
import { completeFlowOrGetUrl } from "../client";
import { createSessionAndUpdateCookie } from "./cookie";
type VerifyPasskeyCommand = {
passkeyId: string;
@@ -74,6 +75,11 @@ export async function registerPasskeyLink(
if (command.sessionId) {
// Session-based flow (existing logic)
const sessionCookie = await getSessionCookieById({ sessionId: command.sessionId });
if (!sessionCookie) {
return { error: "Could not get session cookie" };
}
session = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token });
if (!session?.session?.factors?.user?.id) {
@@ -142,10 +148,11 @@ export async function registerPasskeyLink(
},
});
createdSession = await createSessionAndUpdateCookie({
const result = await createSessionAndUpdateCookie({
checks,
requestId: undefined, // No requestId in passkey registration context, TODO: consider if needed
});
createdSession = result.session;
if (!createdSession) {
return { error: "Could not create session" };
@@ -196,6 +203,11 @@ export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) {
const sessionCookie = await getSessionCookieById({
sessionId: command.sessionId,
});
if (!sessionCookie) {
throw new Error("Could not get session cookie");
}
const session = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token });
const userId = session?.session?.factors?.user?.id;
@@ -241,56 +253,41 @@ export async function sendPasskey(command: SendPasskeyCommand) {
const t = await getTranslations("passkey");
const recentSession = sessionId
? await getSessionCookieById({ sessionId })
: loginName
? await getSessionCookieByLoginName({ loginName, organization })
: await getMostRecentSessionCookie();
const result = await updateOrCreateSession({
loginName,
sessionId,
organization,
checks,
requestId,
lifetime: command.lifetime,
});
if (!recentSession) {
return {
error: t("verify.errors.couldNotFindSession"),
};
if (result.error) {
// try to interpret validation errors as translation keys if possible, or fallback to generic
// For now returning the error string directly as key or default
return { error: result.error };
}
// transformation to partial session for compatibility
const session = {
id: result.sessionId,
factors: result.factors,
// @ts-ignore
challenges: result.challenges,
};
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const loginSettings = await getLoginSettings({ serviceConfig, organization });
let lifetime = command.lifetime; // Use provided lifetime first
if (!lifetime) {
lifetime = checks?.webAuthN
? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey
: checks?.otpEmail || checks?.otpSms
? loginSettings?.secondFactorCheckLifetime
: undefined;
}
if (!lifetime || !lifetime.seconds) {
console.warn("No passkey lifetime provided, defaulting to 24 hours");
lifetime = {
seconds: BigInt(60 * 60 * 24), // default to 24 hours
nanos: 0,
} as Duration;
}
const session = await setSessionAndUpdateCookie({
recentCookie: recentSession,
checks,
requestId,
lifetime,
});
if (!session || !session?.factors?.user?.id) {
return { error: t("verify.errors.couldNotUpdateSession") };
const userId = session?.factors?.user?.id;
if (!userId) {
return { error: t("verify.errors.couldNotFindSession") };
}
let userResponse;
try {
userResponse = await getUserByID({ serviceConfig, userId: session?.factors?.user?.id });
userResponse = await getUserByID({ serviceConfig, userId });
} catch (error) {
console.error("Error fetching user by ID:", error);
return { error: t("verify.errors.couldNotGetUser") };
@@ -302,7 +299,7 @@ export async function sendPasskey(command: SendPasskeyCommand) {
const humanUser = userResponse.user.type.case === "human" ? userResponse.user.type.value : undefined;
const emailVerificationCheck = checkEmailVerification(session, humanUser, organization, requestId);
const emailVerificationCheck = checkEmailVerification(session as any, humanUser, organization, requestId);
if (emailVerificationCheck?.redirect) {
return emailVerificationCheck;
@@ -0,0 +1,156 @@
import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { sendPassword } from "./password";
// Mock dependencies
vi.mock("next/headers", () => ({
headers: vi.fn(),
}));
vi.mock("@zitadel/client", () => ({
create: vi.fn((schema, data) => data),
ConnectError: class extends Error {
code: number;
constructor(msg: string, code: number) {
super(msg);
this.code = code;
}
},
timestampDate: (ts: any) => new Date(ts.seconds * 1000),
}));
vi.mock("@zitadel/client/v2", () => ({
createUserServiceClient: vi.fn(),
}));
vi.mock("../service-url", () => ({
getServiceConfig: vi.fn(),
}));
vi.mock("../zitadel", () => ({
getLoginSettings: vi.fn(),
listAuthenticationMethodTypes: vi.fn(),
getSession: vi.fn(),
setPassword: vi.fn(),
createServerTransport: vi.fn(),
listUsers: vi.fn(),
getLockoutSettings: vi.fn(),
getPasswordExpirySettings: vi.fn(),
getUserByID: vi.fn(),
searchUsers: vi.fn(),
}));
vi.mock("../cookies", () => ({
getSessionCookieByLoginName: vi.fn(),
getSessionCookieById: vi.fn(),
}));
vi.mock("../client", () => ({
completeFlowOrGetUrl: vi.fn(),
}));
vi.mock("@/lib/server/cookie", () => ({
createSessionAndUpdateCookie: vi.fn(),
setSessionAndUpdateCookie: vi.fn(),
}));
vi.mock("../verify-helper", () => ({
checkPasswordChangeRequired: vi.fn(),
checkEmailVerification: vi.fn(),
checkMFAFactors: vi.fn(),
}));
vi.mock("next-intl/server", () => ({
getTranslations: vi.fn(() => (key: string) => key),
}));
describe("sendPassword", () => {
let mockHeaders: any;
let mockGetServiceConfig: any;
let mockGetSessionCookieByLoginName: any;
let mockSearchUsers: any;
let mockGetLoginSettings: any;
let mockCreateSessionAndUpdateCookie: any;
let mockCompleteFlowOrGetUrl: any;
let mockListAuthenticationMethodTypes: any;
let mockCheckMFAFactors: any;
beforeEach(async () => {
vi.clearAllMocks();
const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url");
const { getSessionCookieByLoginName } = await import("../cookies");
const { getLoginSettings, listAuthenticationMethodTypes, searchUsers } = await import("../zitadel");
const { createSessionAndUpdateCookie } = await import("@/lib/server/cookie");
const { completeFlowOrGetUrl } = await import("../client");
const { checkMFAFactors } = await import("../verify-helper");
mockHeaders = vi.mocked(headers);
mockGetServiceConfig = vi.mocked(getServiceConfig);
mockGetSessionCookieByLoginName = vi.mocked(getSessionCookieByLoginName);
mockSearchUsers = vi.mocked(searchUsers);
mockGetLoginSettings = vi.mocked(getLoginSettings);
mockCreateSessionAndUpdateCookie = vi.mocked(createSessionAndUpdateCookie);
mockCompleteFlowOrGetUrl = vi.mocked(completeFlowOrGetUrl);
mockListAuthenticationMethodTypes = vi.mocked(listAuthenticationMethodTypes);
mockCheckMFAFactors = vi.mocked(checkMFAFactors);
mockHeaders.mockResolvedValue({});
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } });
});
test("should create session and verify password when no session exists", async () => {
// 1. No existing session
mockGetSessionCookieByLoginName.mockResolvedValue(null);
// 2. User exists
mockSearchUsers.mockResolvedValue({
result: [{ userId: "user123", type: { case: "human", value: {} }, state: UserState.ACTIVE }],
});
// 3. Login settings
mockGetLoginSettings.mockResolvedValue({ passwordCheckLifetime: { seconds: BigInt(86400) } });
// 4. Create session success
mockCreateSessionAndUpdateCookie.mockResolvedValue({
session: {
id: "new-session-id",
factors: {
user: { id: "user123", loginName: "testuser", organizationId: "org123" },
password: { verifiedAt: { seconds: 100 } },
},
},
sessionCookie: {
id: "new-session-id",
token: "token123",
organization: "org123",
},
});
// 5. Auth methods (no MFA)
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
// 6. MFA check passes
mockCheckMFAFactors.mockResolvedValue(null);
// 7. Redirect
mockCompleteFlowOrGetUrl.mockResolvedValue({ redirect: "/next-page" });
const result = await sendPassword({
loginName: "testuser",
checks: create(ChecksSchema, { password: { password: "password123" } }),
});
// Verification
expect(mockSearchUsers).toHaveBeenCalledWith(expect.objectContaining({ searchValue: "testuser" }));
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
expect(mockCompleteFlowOrGetUrl).toHaveBeenCalled();
expect(result).toEqual({ redirect: "/next-page" });
});
});
+497
View File
@@ -0,0 +1,497 @@
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { changePassword, checkSessionAndSetPassword, resetPassword, sendPassword } from "./password";
// Mock dependencies
vi.mock("next/headers", () => ({
headers: vi.fn(),
}));
vi.mock("@zitadel/client", () => ({
create: vi.fn(),
ConnectError: class extends Error {
code: number;
constructor(msg: string, code: number) {
super(msg);
this.code = code;
}
},
timestampDate: (ts: any) => new Date(ts.seconds * 1000),
}));
vi.mock("@zitadel/client/v2", () => ({
createUserServiceClient: vi.fn(),
}));
vi.mock("../service-url", () => ({
getServiceConfig: vi.fn(),
}));
vi.mock("../zitadel", () => ({
getLoginSettings: vi.fn(),
listAuthenticationMethodTypes: vi.fn(),
getSession: vi.fn(),
setPassword: vi.fn(),
createServerTransport: vi.fn(),
getLockoutSettings: vi.fn(),
passwordReset: vi.fn(),
getUserByID: vi.fn(),
setUserPassword: vi.fn(),
getPasswordExpirySettings: vi.fn(),
searchUsers: vi.fn(),
}));
vi.mock("./cookie", () => ({
createSessionAndUpdateCookie: vi.fn(),
setSessionAndUpdateCookie: vi.fn(),
}));
vi.mock("../cookies", () => ({
getSessionCookieById: vi.fn(),
getSessionCookieByLoginName: vi.fn(),
}));
vi.mock("next-intl/server", () => ({
getTranslations: vi.fn(() => (key: string) => key),
}));
vi.mock("../verify-helper", () => ({
checkEmailVerification: vi.fn(),
checkMFAFactors: vi.fn(),
checkPasswordChangeRequired: vi.fn(),
checkUserVerification: vi.fn(),
}));
vi.mock("../client", () => ({
completeFlowOrGetUrl: vi.fn(),
}));
describe("checkSessionAndSetPassword", () => {
let mockHeaders: any;
let mockGetServiceConfig: any;
let mockGetSessionCookieById: any;
let mockGetSession: any;
let mockListAuthenticationMethodTypes: any;
let mockGetLoginSettings: any;
let mockSetPassword: any; // Service account
let mockCreateUserServiceClient: any; // User session
let mockSetPasswordUser: any;
beforeEach(async () => {
vi.clearAllMocks();
const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url");
const { getSessionCookieById } = await import("../cookies");
const { getSession, listAuthenticationMethodTypes, getLoginSettings, setPassword } = await import("../zitadel");
const { createUserServiceClient } = await import("@zitadel/client/v2");
mockHeaders = vi.mocked(headers);
mockGetServiceConfig = vi.mocked(getServiceConfig);
mockGetSessionCookieById = vi.mocked(getSessionCookieById);
mockGetSession = vi.mocked(getSession);
mockListAuthenticationMethodTypes = vi.mocked(listAuthenticationMethodTypes);
mockGetLoginSettings = vi.mocked(getLoginSettings);
mockSetPassword = vi.mocked(setPassword);
mockCreateUserServiceClient = vi.mocked(createUserServiceClient);
mockHeaders.mockResolvedValue({});
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } });
// Default session setup
mockGetSessionCookieById.mockResolvedValue({ id: "session123", token: "token123" });
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: Math.floor(Date.now() / 1000) } }, // Password verified recently
},
},
});
// Default: Only password method
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
// Default: No forced MFA
mockGetLoginSettings.mockResolvedValue({ forceMfa: false });
// Mock user service client
mockSetPasswordUser = vi.fn().mockResolvedValue({});
mockCreateUserServiceClient.mockReturnValue({
setPassword: mockSetPasswordUser,
});
mockSetPassword.mockResolvedValue({});
});
test("should use user session when no MFA is configured", async () => {
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(mockCreateUserServiceClient).toHaveBeenCalled();
expect(mockSetPasswordUser).toHaveBeenCalled();
expect(mockSetPassword).not.toHaveBeenCalled();
});
test("should use service account when MFA is configured but NOT verified in session", async () => {
// User has TOTP configured
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
});
// Session only has password verified (no OTP)
const now = Math.floor(Date.now() / 1000);
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: now - 60 } }, // Verified 1 minute ago
// otp missing
},
},
});
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
// EXPECTATION: Should use service account (mockSetPassword)
// CURRENTLY: Will fail this test and use user session
expect(mockSetPassword).toHaveBeenCalled();
expect(mockCreateUserServiceClient).not.toHaveBeenCalled();
});
test("should use user session when MFA is configured AND verified in session", async () => {
// User has TOTP configured
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
});
// Session has both password and OTP verified
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: Math.floor(Date.now() / 1000) } },
totp: { verifiedAt: { seconds: Math.floor(Date.now() / 1000) } }, // Verified
},
},
});
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(mockCreateUserServiceClient).toHaveBeenCalled();
expect(mockSetPassword).not.toHaveBeenCalled();
});
test("should fail when MFA is configured but not verified, and password verification is too old", async () => {
// User has TOTP configured
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
});
// Session has password verified 10 minutes ago (600 seconds)
const now = Math.floor(Date.now() / 1000);
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: now - 600 } },
// otp missing
},
},
});
const result = await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(result).toEqual({ error: "errors.passwordVerificationTooOld" });
expect(mockSetPassword).not.toHaveBeenCalled();
});
test("should use service account when MFA is configured but not verified, and password verification is recent", async () => {
// User has TOTP configured
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
});
// Session has password verified 1 minute ago (60 seconds)
const now = Math.floor(Date.now() / 1000);
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: now - 60 } },
// otp missing
},
},
});
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(mockSetPassword).toHaveBeenCalled();
});
});
describe("sendPassword", () => {
let mockHeaders: any;
let mockGetServiceConfig: any;
let mockGetSessionCookieByLoginName: any;
let mockSearchUsers: any;
let mockGetLoginSettings: any;
let mockCreateSessionAndUpdateCookie: any;
let mockSetSessionAndUpdateCookie: any;
let mockGetLockoutSettings: any;
beforeEach(async () => {
vi.clearAllMocks();
const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url");
const { getSessionCookieByLoginName } = await import("../cookies");
const { getLoginSettings, getLockoutSettings, searchUsers } = await import("../zitadel");
const { createSessionAndUpdateCookie, setSessionAndUpdateCookie } = await import("./cookie");
mockHeaders = vi.mocked(headers);
mockGetServiceConfig = vi.mocked(getServiceConfig);
mockGetSessionCookieByLoginName = vi.mocked(getSessionCookieByLoginName);
mockSearchUsers = vi.mocked(searchUsers);
mockGetLoginSettings = vi.mocked(getLoginSettings);
mockCreateSessionAndUpdateCookie = vi.mocked(createSessionAndUpdateCookie);
mockSetSessionAndUpdateCookie = vi.mocked(setSessionAndUpdateCookie);
mockCreateSessionAndUpdateCookie = vi.mocked(createSessionAndUpdateCookie);
mockSetSessionAndUpdateCookie = vi.mocked(setSessionAndUpdateCookie);
mockGetLockoutSettings = vi.mocked(getLockoutSettings);
const { completeFlowOrGetUrl } = await import("../client");
vi.mocked(completeFlowOrGetUrl).mockResolvedValue({ redirect: "https://example.com" });
// eslint-disable-next-line
const verifyHelper = await import("../verify-helper");
vi.mocked(verifyHelper.checkPasswordChangeRequired).mockReturnValue(undefined);
vi.mocked(verifyHelper.checkEmailVerification).mockReturnValue(undefined);
vi.mocked(verifyHelper.checkMFAFactors).mockResolvedValue(undefined);
mockHeaders.mockResolvedValue({});
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } });
});
test("should return generic error when user not found and ignoreUnknownUsernames is true", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue(null);
mockSearchUsers.mockResolvedValue({ result: [] });
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
const result = await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "password" } } as any,
});
expect(result).toEqual({ error: "errors.failedToAuthenticateNoLimit" });
});
test("should return specific error when user not found and ignoreUnknownUsernames is false", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue(null);
mockSearchUsers.mockResolvedValue({ result: [] });
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: false });
const result = await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "password" } } as any,
});
expect(result).toEqual({ error: "errors.couldNotVerifyPassword" });
});
test("should return generic error when password verification fails and ignoreUnknownUsernames is true", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue(null);
mockSearchUsers.mockResolvedValue({
result: [{ userId: "user123", type: { case: "human", value: {} }, state: 1 }],
});
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
mockCreateSessionAndUpdateCookie.mockRejectedValue({ failedAttempts: 1 });
const result = await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "wrong" } } as any,
});
expect(result).toEqual({ error: "errors.failedToAuthenticateNoLimit" });
});
test("should return generic error when session creation fails with unknown error and ignoreUnknownUsernames is true", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue(null);
mockSearchUsers.mockResolvedValue({
result: [{ userId: "user123", type: { case: "human", value: {} }, state: 1 }],
});
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
// Simulate an error that is NOT a failed attempt error (e.g. database error)
mockCreateSessionAndUpdateCookie.mockRejectedValue(new Error("Some internal error"));
const result = await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "correct" } } as any,
});
expect(result).toEqual({ error: "errors.failedToAuthenticateNoLimit" });
});
test("should return specific error with lockout info when password verification fails and ignoreUnknownUsernames is false", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue(null);
mockSearchUsers.mockResolvedValue({
result: [{ userId: "user123", type: { case: "human", value: {} }, state: 1 }],
});
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: false });
mockCreateSessionAndUpdateCookie.mockRejectedValue({ failedAttempts: 1 });
mockGetLockoutSettings.mockResolvedValue({ maxPasswordAttempts: BigInt(5) });
const result = await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "wrong" } } as any,
});
expect(result).toEqual({
error: "errors.failedToAuthenticate",
});
});
test("should recreate session when session verification fails with keys/session terminated error and ignoreUnknownUsernames is true", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue({
id: "session123",
token: "token123",
organization: "org123",
});
const terminatedError = { message: "session already terminated" };
mockSetSessionAndUpdateCookie.mockRejectedValue(terminatedError);
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
mockSearchUsers.mockResolvedValue({
result: [
{
userId: "user123",
type: {
case: "human",
value: {
email: { email: "[email protected]", isVerified: true },
phone: { phone: "+1234567890", isVerified: true },
},
},
state: 1, // Active
preferredLoginName: "[email protected]",
},
],
});
mockCreateSessionAndUpdateCookie.mockResolvedValue({
session: { factors: { user: { id: "user123", loginName: "[email protected]" } } },
sessionCookie: { id: "newSession", token: "newToken" },
});
// Execute
await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "password" } } as any,
});
expect(mockSetSessionAndUpdateCookie).toHaveBeenCalled();
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
});
});
describe("resetPassword", () => {
let mockHeaders: any;
let mockGetServiceConfig: any;
let mockSearchUsers: any;
let mockGetLoginSettings: any;
let mockPasswordReset: any;
beforeEach(async () => {
vi.clearAllMocks();
const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url");
const { getLoginSettings, passwordReset, searchUsers } = await import("../zitadel");
mockHeaders = vi.mocked(headers);
mockGetServiceConfig = vi.mocked(getServiceConfig);
mockSearchUsers = vi.mocked(searchUsers);
mockGetLoginSettings = vi.mocked(getLoginSettings);
mockPasswordReset = vi.mocked(passwordReset);
mockHeaders.mockResolvedValue({ get: vi.fn(() => "example.com") });
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } });
});
test("should return generic success when user not found and ignoreUnknownUsernames is true", async () => {
mockSearchUsers.mockResolvedValue({ result: [] });
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
const result = await resetPassword({
loginName: "[email protected]",
});
expect(result).toEqual({});
expect(mockPasswordReset).not.toHaveBeenCalled();
});
test("should return specific error when user not found and ignoreUnknownUsernames is false", async () => {
mockSearchUsers.mockResolvedValue({ result: [] });
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: false });
const result = await resetPassword({
loginName: "[email protected]",
});
expect(result).toEqual({ error: "errors.couldNotSendResetLink" });
expect(mockPasswordReset).not.toHaveBeenCalled();
});
});
describe("changePassword", () => {
let mockHeaders: any;
let mockGetServiceConfig: any;
let mockGetUserByID: any;
let mockGetLoginSettings: any;
let mockSetUserPassword: any;
beforeEach(async () => {
vi.clearAllMocks();
const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url");
const { getUserByID, getLoginSettings, setUserPassword } = await import("../zitadel");
mockHeaders = vi.mocked(headers);
mockGetServiceConfig = vi.mocked(getServiceConfig);
mockGetUserByID = vi.mocked(getUserByID);
mockGetLoginSettings = vi.mocked(getLoginSettings);
mockSetUserPassword = vi.mocked(setUserPassword);
mockHeaders.mockResolvedValue({ get: vi.fn(() => "example.com") });
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } });
});
test("should return generic error when user not found and ignoreUnknownUsernames is true", async () => {
mockGetUserByID.mockResolvedValue({}); // User not found
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
const result = await changePassword({
userId: "unknown",
password: "newpassword",
});
expect(result).toEqual({ error: "set.errors.couldNotSetPassword" });
expect(mockSetUserPassword).not.toHaveBeenCalled();
});
test("should return specific error when user not found and ignoreUnknownUsernames is false", async () => {
mockGetUserByID.mockResolvedValue({}); // User not found
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: false });
const result = await changePassword({
userId: "unknown",
password: "newpassword",
});
expect(result).toEqual({ error: "errors.couldNotSendResetLink" });
expect(mockSetUserPassword).not.toHaveBeenCalled();
});
});
+230 -87
View File
@@ -8,23 +8,23 @@ import {
getSession,
getUserByID,
listAuthenticationMethodTypes,
listUsers,
passwordReset,
searchUsers,
ServiceConfig,
setPassword,
setUserPassword,
} from "@/lib/zitadel";
import { ConnectError, create, Duration } from "@zitadel/client";
import { ConnectError, create, Duration, timestampDate } from "@zitadel/client";
import { createUserServiceClient } from "@zitadel/client/v2";
import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { SetPasswordRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { AuthenticationMethodType, SetPasswordRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { completeFlowOrGetUrl } from "../client";
import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies";
import { getServiceConfig } from "../service-url";
import { getPublicHostWithProtocol } from "./host";
import {
checkEmailVerification,
checkMFAFactors,
@@ -32,11 +32,12 @@ import {
checkUserVerification,
} from "../verify-helper";
import { createServerTransport } from "../zitadel";
import { getTranslations } from "next-intl/server";
import { getPublicHostWithProtocol } from "./host";
type ResetPasswordCommand = {
loginName: string;
organization?: string;
defaultOrganization?: string;
requestId?: string;
};
@@ -49,12 +50,67 @@ export async function resetPassword(command: ResetPasswordCommand) {
// Get the original host that the user sees with protocol
const hostWithProtocol = await getPublicHostWithProtocol(_headers);
const users = await listUsers({ serviceConfig, loginName: command.loginName, organizationId: command.organization });
const loginSettings = await getLoginSettings({
serviceConfig,
organization: command.organization ?? command.defaultOrganization,
});
if (!users.details || users.details.totalResult !== BigInt(1) || !users.result[0].userId) {
if (!loginSettings) {
return { error: t("errors.couldNotSendResetLink") };
}
const userId = users.result[0].userId;
const searchResult = await searchUsers({
serviceConfig,
searchValue: command.loginName,
organizationId: command.organization,
loginSettings,
});
if (
!searchResult ||
!("result" in searchResult) ||
!searchResult.result ||
searchResult.result.length !== 1 ||
!searchResult.result[0].userId
) {
if (loginSettings?.ignoreUnknownUsernames) {
await new Promise((resolve) => setTimeout(resolve, 2000));
return {};
}
return { error: t("errors.couldNotSendResetLink") };
}
const user = searchResult.result[0];
const humanUser = user.type.case === "human" ? user.type.value : undefined;
const userLoginSettings = await getLoginSettings({ serviceConfig, organization: user.details?.resourceOwner });
if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== command.loginName) {
if (userLoginSettings?.ignoreUnknownUsernames) {
await new Promise((resolve) => setTimeout(resolve, 2000));
return {};
}
return { error: t("errors.couldNotSendResetLink") };
}
} else if (userLoginSettings?.disableLoginWithEmail) {
if (user.preferredLoginName !== command.loginName || humanUser?.phone?.phone !== command.loginName) {
if (userLoginSettings?.ignoreUnknownUsernames) {
await new Promise((resolve) => setTimeout(resolve, 2000));
return {};
}
return { error: t("errors.couldNotSendResetLink") };
}
} else if (userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== command.loginName || humanUser?.email?.email !== command.loginName) {
if (userLoginSettings?.ignoreUnknownUsernames) {
await new Promise((resolve) => setTimeout(resolve, 2000));
return {};
}
return { error: t("errors.couldNotSendResetLink") };
}
}
const userId = user.userId;
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
@@ -70,6 +126,7 @@ export async function resetPassword(command: ResetPasswordCommand) {
export type UpdateSessionCommand = {
loginName: string;
organization?: string;
defaultOrganization?: string;
checks: Checks;
requestId?: string;
};
@@ -82,35 +139,123 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err
let sessionCookie = await getSessionCookieByLoginName({
loginName: command.loginName,
organization: command.organization,
}).catch((error) => {
console.warn("Ignored error:", error);
});
let session;
let user: User;
let loginSettings: LoginSettings | undefined;
let user: User | undefined;
let loginSettingsByContext: LoginSettings | undefined;
let loginSettingsByUser: LoginSettings | undefined;
if (sessionCookie) {
try {
loginSettingsByUser = await getLoginSettings({ serviceConfig, organization: sessionCookie.organization });
if (loginSettingsByUser) {
let lifetime = loginSettingsByUser.passwordCheckLifetime;
if (!lifetime || !lifetime.seconds) {
console.warn("No password lifetime provided, defaulting to 24 hours");
lifetime = {
seconds: BigInt(60 * 60 * 24), // default to 24 hours
nanos: 0,
} as Duration;
}
session = await setSessionAndUpdateCookie({
recentCookie: sessionCookie,
checks: command.checks,
requestId: command.requestId,
lifetime,
});
} else {
// Force fallback if settings can't be loaded
throw new Error("Could not load login settings");
}
} catch {
console.warn("[Password] Could not update session");
// If the session was terminated or any other error occurred during update,
// we fall back to creating a new session.
sessionCookie = undefined;
session = undefined;
}
}
if (!sessionCookie) {
const users = await listUsers({ serviceConfig, loginName: command.loginName, organizationId: command.organization });
if (!loginSettingsByContext) {
loginSettingsByContext = await getLoginSettings({
serviceConfig,
organization: command.organization ?? command.defaultOrganization,
});
}
if (users.details?.totalResult == BigInt(1) && users.result[0].userId) {
user = users.result[0];
// Force fallback if settings can't be loaded
if (!loginSettingsByContext) {
// this is a fake error message to hide that the user does not even exist
return { error: t("errors.couldNotVerifyPassword") };
}
const searchResult = await searchUsers({
serviceConfig,
searchValue: command.loginName,
organizationId: command.organization,
loginSettings: loginSettingsByContext,
});
if (
searchResult &&
"result" in searchResult &&
searchResult.result &&
searchResult.result.length === 1 &&
searchResult.result[0].userId
) {
user = searchResult.result[0];
const humanUser = user.type.case === "human" ? user.type.value : undefined;
const userLoginSettings = await getLoginSettings({ serviceConfig, organization: user.details?.resourceOwner });
// recheck login settings after user discovery, as the search might have been done without org scope
if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== command.loginName) {
// emulate user not found to prevent enumeration (use context settings not user settings)
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
return { error: t("errors.couldNotVerifyPassword") };
}
} else if (userLoginSettings?.disableLoginWithEmail) {
if (user.preferredLoginName !== command.loginName || humanUser?.phone?.phone !== command.loginName) {
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
return { error: t("errors.couldNotVerifyPassword") };
}
} else if (userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== command.loginName || humanUser?.email?.email !== command.loginName) {
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
return { error: t("errors.couldNotVerifyPassword") };
}
}
const checks = create(ChecksSchema, {
user: { search: { case: "userId", value: users.result[0].userId } },
user: { search: { case: "userId", value: user.userId } },
password: { password: command.checks.password?.password },
});
loginSettings = await getLoginSettings({ serviceConfig, organization: command.organization });
try {
session = await createSessionAndUpdateCookie({
const result = await createSessionAndUpdateCookie({
checks,
requestId: command.requestId,
lifetime: loginSettings?.passwordCheckLifetime,
lifetime: loginSettingsByContext?.passwordCheckLifetime,
});
session = result.session;
sessionCookie = result.sessionCookie;
} catch (error: any) {
if ("failedAttempts" in error && error.failedAttempts) {
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
const lockoutSettings = await getLockoutSettings({ serviceConfig, orgId: command.organization });
const hasLimit =
@@ -126,81 +271,47 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err
}),
};
}
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
return { error: t("errors.couldNotCreateSessionForUser") };
}
} else {
// this is a fake error message to hide that the user does not even exist
return { error: "Could not verify password" };
}
// this is a fake error message to hide that the user does not even exist
return { error: t("errors.couldNotVerifyPassword") };
} else {
loginSettings = await getLoginSettings({ serviceConfig, organization: sessionCookie.organization });
if (!loginSettings) {
return { error: "Could not load login settings" };
}
let lifetime = loginSettings.passwordCheckLifetime;
if (!lifetime || !lifetime.seconds) {
console.warn("No password lifetime provided, defaulting to 24 hours");
lifetime = {
seconds: BigInt(60 * 60 * 24), // default to 24 hours
nanos: 0,
} as Duration;
}
try {
session = await setSessionAndUpdateCookie({
recentCookie: sessionCookie,
checks: command.checks,
requestId: command.requestId,
lifetime,
});
} catch (error: any) {
if ("failedAttempts" in error && error.failedAttempts) {
const lockoutSettings = await getLockoutSettings({ serviceConfig, orgId: command.organization });
const hasLimit =
lockoutSettings?.maxPasswordAttempts !== undefined && lockoutSettings?.maxPasswordAttempts > BigInt(0);
const locked = hasLimit && error.failedAttempts >= lockoutSettings?.maxPasswordAttempts;
const messageKey = hasLimit ? "errors.failedToAuthenticate" : "errors.failedToAuthenticateNoLimit";
return {
error: t(messageKey, {
failedAttempts: error.failedAttempts,
maxPasswordAttempts: hasLimit ? (lockoutSettings?.maxPasswordAttempts).toString() : "?",
lockoutMessage: locked ? t("errors.accountLockedContactAdmin") : "",
}),
};
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
throw error;
return { error: t("errors.couldNotVerifyPassword") };
}
}
if (!session?.factors?.user?.id) {
return { error: t("errors.couldNotCreateSessionForUser") };
if (!session?.factors?.user?.id) {
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
return { error: t("errors.couldNotCreateSessionForUser") };
}
if (!user) {
const userResponse = await getUserByID({ serviceConfig, userId: session?.factors?.user?.id });
if (!userResponse.user) {
return { error: t("errors.userNotFound") };
}
user = userResponse.user;
}
if (!loginSettings) {
loginSettings = await getLoginSettings({
serviceConfig,
organization: command.organization ?? session.factors?.user?.organizationId,
});
if (!session?.factors?.user?.id || !sessionCookie) {
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
return { error: t("errors.couldNotCreateSessionForUser") };
}
if (!session?.factors?.user?.id || !sessionCookie) {
return { error: t("errors.couldNotCreateSessionForUser") };
if (!loginSettingsByUser) {
loginSettingsByUser = await getLoginSettings({
serviceConfig,
organization: command.organization ?? session.factors?.user?.organizationId ?? command.defaultOrganization,
});
}
const humanUser = user.type.case === "human" ? user.type.value : undefined;
@@ -251,7 +362,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err
const mfaFactorCheck = await checkMFAFactors(
serviceConfig,
session,
loginSettings,
loginSettingsByUser,
authMethods,
command.organization,
command.requestId,
@@ -270,7 +381,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err
requestId: command.requestId,
organization: command.organization ?? session.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
loginSettingsByUser?.defaultRedirectUri,
);
console.log("Password auth: OIDC/SAML flow result:", result);
@@ -290,7 +401,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err
loginName: session.factors.user.loginName,
organization: session.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
loginSettingsByUser?.defaultRedirectUri,
);
// Safety net - ensure we always return a valid object
@@ -303,7 +414,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err
}
// this function lets users with code set a password or users with valid User Verification Check
export async function changePassword(command: { code?: string; userId: string; password: string }) {
export async function changePassword(command: { code?: string; userId: string; password: string; organization?: string }) {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const t = await getTranslations("password");
@@ -312,6 +423,10 @@ export async function changePassword(command: { code?: string; userId: string; p
const { user } = await getUserByID({ serviceConfig, userId: command.userId });
if (!user || user.userId !== command.userId) {
const loginSettings = await getLoginSettings({ serviceConfig, organization: command.organization });
if (loginSettings?.ignoreUnknownUsernames) {
return { error: t("set.errors.couldNotSetPassword") };
}
return { error: t("errors.couldNotSendResetLink") };
}
const userId = user.userId;
@@ -352,11 +467,9 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS
const { serviceConfig } = getServiceConfig(_headers);
const t = await getTranslations("password");
let sessionCookie;
try {
sessionCookie = await getSessionCookieById({ sessionId });
} catch (error) {
console.error("Error getting session cookie:", error);
const sessionCookie = await getSessionCookieById({ sessionId });
if (!sessionCookie) {
return { error: "Could not load session cookie" };
}
@@ -407,8 +520,38 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS
const forceMfa = !!(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly);
const mfaFactors = authmethods.authMethodTypes.filter(
(m) =>
m === AuthenticationMethodType.TOTP ||
m === AuthenticationMethodType.OTP_SMS ||
m === AuthenticationMethodType.OTP_EMAIL ||
m === AuthenticationMethodType.U2F,
);
const hasMfa = mfaFactors.length > 0;
const mfaVerified =
!!session.factors?.totp?.verifiedAt ||
!!session.factors?.otpSms?.verifiedAt ||
!!session.factors?.otpEmail?.verifiedAt ||
!!session.factors?.webAuthN?.verifiedAt;
// check if the password factor is set and not older than 5 minutes
const passwordVerifiedAt = session.factors?.password?.verifiedAt;
if (!passwordVerifiedAt) {
return { error: t("errors.passwordVerificationMissing") };
}
const passwordVerifiedDate = timestampDate(passwordVerifiedAt);
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
if (passwordVerifiedDate < fiveMinutesAgo) {
return { error: t("errors.passwordVerificationTooOld") };
}
// if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user
if (forceMfa) {
// also if the user has no MFA but it is not verified, we use the service account
if (forceMfa || (hasMfa && !mfaVerified)) {
console.log("Set password using service account due to enforced MFA without existing MFA methods");
return setPassword({ serviceConfig, payload }).catch((error) => {
// throw error if failed precondition (ex. User is not yet initialized)
+15 -12
View File
@@ -32,7 +32,9 @@ export async function registerUser(command: RegisterUserCommand) {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const addResponse = await addHumanUser({ serviceConfig, email: command.email,
const addResponse = await addHumanUser({
serviceConfig,
email: command.email,
firstName: command.firstName,
lastName: command.lastName,
password: command.password ? command.password : undefined,
@@ -43,8 +45,7 @@ export async function registerUser(command: RegisterUserCommand) {
return { error: t("errors.couldNotCreateUser") };
}
const loginSettings = await getLoginSettings({ serviceConfig, organization: command.organization,
});
const loginSettings = await getLoginSettings({ serviceConfig, organization: command.organization });
let checkPayload: any = {
user: { search: { case: "userId", value: addResponse.userId } },
@@ -59,11 +60,12 @@ export async function registerUser(command: RegisterUserCommand) {
const checks = create(ChecksSchema, checkPayload);
const session = await createSessionAndUpdateCookie({
const result = await createSessionAndUpdateCookie({
checks,
requestId: command.requestId,
lifetime: command.password ? loginSettings?.passwordCheckLifetime : undefined,
});
const session = result.session;
if (!session || !session.factors?.user) {
return { error: t("errors.couldNotCreateSession") };
@@ -96,8 +98,7 @@ export async function registerUser(command: RegisterUserCommand) {
return { redirect: "/passkey/set?" + params };
} else {
const userResponse = await getUserByID({ serviceConfig, userId: session?.factors?.user?.id,
});
const userResponse = await getUserByID({ serviceConfig, userId: session?.factors?.user?.id });
if (!userResponse.user) {
return { error: t("errors.userNotFound") };
@@ -158,7 +159,9 @@ export async function registerUserAndLinkToIDP(command: RegisterUserAndLinkToIDP
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const addUserResponse = await addHumanUser({ serviceConfig, email: command.email,
const addUserResponse = await addHumanUser({
serviceConfig,
email: command.email,
firstName: command.firstName,
lastName: command.lastName,
organization: command.organization,
@@ -168,10 +171,11 @@ export async function registerUserAndLinkToIDP(command: RegisterUserAndLinkToIDP
return { error: t("errors.couldNotCreateUser") };
}
const loginSettings = await getLoginSettings({ serviceConfig, organization: command.organization,
});
const loginSettings = await getLoginSettings({ serviceConfig, organization: command.organization });
const idpLink = await addIDPLink({ serviceConfig, idp: {
const idpLink = await addIDPLink({
serviceConfig,
idp: {
id: command.idpId,
userId: command.idpUserId,
userName: command.idpUserName,
@@ -215,8 +219,7 @@ export async function registerUserAndLinkToIDP(command: RegisterUserAndLinkToIDP
// check if user has MFA methods
let authMethods;
if (session.factors?.user?.id) {
const response = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id,
});
const response = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id });
if (response.authMethodTypes && response.authMethodTypes.length) {
authMethods = response.authMethodTypes;
}
+106 -33
View File
@@ -1,17 +1,18 @@
"use server";
import { setSessionAndUpdateCookie } from "@/lib/server/cookie";
import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "@/lib/server/cookie";
import {
deleteSession,
getLoginSettings,
getSecuritySettings,
humanMFAInitSkipped,
listAuthenticationMethodTypes,
listUsers,
} from "@/lib/zitadel";
import { Duration } from "@zitadel/client";
import { create, Duration } from "@zitadel/client";
import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
import { completeFlowOrGetUrl } from "../client";
@@ -109,41 +110,69 @@ export type UpdateSessionCommand = {
lifetime?: Duration;
};
export async function updateSession(options: UpdateSessionCommand) {
let { loginName, sessionId, organization, checks, requestId, challenges } = options;
const recentSession = sessionId
export async function updateOrCreateSession(options: UpdateSessionCommand) {
let { loginName, sessionId, organization, checks, requestId, challenges, lifetime } = options;
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const host = getPublicHost(_headers);
const t = await getTranslations("verify.errors");
if (!host) {
return { error: "Could not get host" }; // Technical error, maybe leave or translate if key exists
}
if (challenges && challenges.webAuthN && !challenges.webAuthN.domain) {
const [hostname] = host.split(":");
challenges.webAuthN.domain = hostname;
}
let recentSession = sessionId
? await getSessionCookieById({ sessionId })
: loginName
? await getSessionCookieByLoginName({ loginName, organization })
: await getMostRecentSessionCookie();
if (!recentSession) {
return {
error: "Could not find session",
};
}
if (!loginName) {
return { error: t("couldNotFindSession") };
}
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const host = getPublicHost(_headers);
const checks = create(ChecksSchema, {
user: { search: { case: "loginName", value: loginName } },
});
if (!host) {
return { error: "Could not get host" };
}
const result = await createSessionAndUpdateCookie({
checks,
challenges,
requestId,
}).catch((error) => {
console.error("Could not create session", error);
return undefined;
});
if (host && challenges && challenges.webAuthN && !challenges.webAuthN.domain) {
const [hostname] = host.split(":");
if (result && "sessionCookie" in result) {
recentSession = result.sessionCookie;
}
challenges.webAuthN.domain = hostname;
if (!recentSession) {
return {
error: t("couldNotFindSession"),
};
}
}
const loginSettings = await getLoginSettings({ serviceConfig, organization });
let lifetime = checks?.webAuthN
? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey
: checks?.otpEmail || checks?.otpSms
? loginSettings?.secondFactorCheckLifetime
: undefined;
if (!lifetime) {
lifetime = checks?.webAuthN
? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey
: checks?.otpEmail || checks?.otpSms
? loginSettings?.secondFactorCheckLifetime
: undefined;
}
if (!lifetime || !lifetime.seconds) {
console.warn("No lifetime provided for session, defaulting to 24 hours");
@@ -153,16 +182,55 @@ export async function updateSession(options: UpdateSessionCommand) {
} as Duration;
}
const session = await setSessionAndUpdateCookie({
recentCookie: recentSession,
checks,
challenges,
requestId,
lifetime,
});
let session;
try {
session = await setSessionAndUpdateCookie({
recentCookie: recentSession,
checks,
challenges,
requestId,
lifetime,
});
} catch (error) {
if (!checks) {
throw error;
}
if (!session) {
return { error: "Could not update session" };
const loginNameForCreation = options.loginName || recentSession.loginName;
const orgForCreation = options.organization || recentSession.organization;
if (!loginNameForCreation) {
throw error;
}
const users = await listUsers({
serviceConfig,
loginName: loginNameForCreation,
organizationId: orgForCreation,
});
if (users.details?.totalResult === BigInt(1) && users.result[0].userId) {
const user = users.result[0];
const newChecks = create(ChecksSchema, {
...checks,
user: { search: { case: "userId", value: user.userId } } as any,
});
const result = await createSessionAndUpdateCookie({
checks: newChecks,
requestId,
lifetime,
challenges,
});
// @ts-ignore
session = { ...result.session, challenges: result.challenges };
} else {
throw error;
}
}
if (!session || ("error" in session && session.error)) {
return { error: t("couldNotUpdateSession") };
}
// if password, check if user has MFA methods
@@ -177,6 +245,7 @@ export async function updateSession(options: UpdateSessionCommand) {
return {
sessionId: session.id,
factors: session.factors,
// @ts-ignore
challenges: session.challenges,
authMethods,
};
@@ -194,6 +263,10 @@ export async function clearSession(options: ClearSessionOptions) {
const sessionCookie = await getSessionCookieById({ sessionId });
if (!sessionCookie) {
return;
}
const deleteResponse = await deleteSession({
serviceConfig,
sessionId: sessionCookie.id,
+4
View File
@@ -67,6 +67,10 @@ export async function verifyU2F(command: VerifyU2FCommand) {
sessionId: command.sessionId,
});
if (!sessionCookie) {
return { error: "Could not get session cookie" };
}
const session = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token });
const userId = session?.session?.factors?.user?.id;
+113
View File
@@ -0,0 +1,113 @@
import { vi, describe, expect, test, beforeEach } from "vitest";
import { sendVerification } from "./verify";
// Mock dependencies
vi.mock("@/lib/zitadel", () => ({
verifyEmail: vi.fn(),
verifyInviteCode: vi.fn(),
getUserByID: vi.fn(),
getSession: vi.fn(),
listAuthenticationMethodTypes: vi.fn(),
getLoginSettings: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(),
cookies: vi.fn(),
}));
vi.mock("../cookies", () => ({
getSessionCookieByLoginName: vi.fn(),
}));
vi.mock("./cookie", () => ({
createSessionAndUpdateCookie: vi.fn(),
}));
vi.mock("../service-url", () => ({
getServiceConfig: vi.fn(() => ({ serviceConfig: {} })),
}));
vi.mock("../fingerprint", () => ({
getOrSetFingerprintId: vi.fn(),
}));
vi.mock("next-intl/server", () => ({
getTranslations: vi.fn(() => (key: string) => key),
}));
import { verifyEmail, getUserByID, getSession, listAuthenticationMethodTypes } from "@/lib/zitadel";
import { getSessionCookieByLoginName } from "../cookies";
import { createSessionAndUpdateCookie } from "./cookie";
import { cookies } from "next/headers";
describe("sendVerification", () => {
let mockVerifyEmail: any;
let mockGetUserByID: any;
let mockGetSession: any;
let mockListAuthenticationMethodTypes: any;
let mockGetSessionCookieByLoginName: any;
let mockCreateSessionAndUpdateCookie: any;
let mockCookies: any;
beforeEach(() => {
mockVerifyEmail = verifyEmail;
mockGetUserByID = getUserByID;
mockGetSession = getSession;
mockListAuthenticationMethodTypes = listAuthenticationMethodTypes;
mockGetSessionCookieByLoginName = getSessionCookieByLoginName;
mockCreateSessionAndUpdateCookie = createSessionAndUpdateCookie;
mockCookies = cookies;
// Default valid checking setup
mockVerifyEmail.mockResolvedValue({});
mockGetUserByID.mockResolvedValue({
user: { userId: "user-1", preferredLoginName: "[email protected]" },
});
mockCookies.mockResolvedValue({
set: vi.fn(),
});
});
test("should handle failing getSession when no auth methods exist (stale cookie scenario)", async () => {
// 1. Cookie exists
mockGetSessionCookieByLoginName.mockResolvedValue({
id: "stale-session-id",
token: "stale-token",
});
// 2. getSession fails (throws or returns null/invalid)
// Simulating call returning nothing useful or throwing
mockGetSession.mockRejectedValue(new Error("Session not found"));
// OR: mockGetSession.mockResolvedValue({}); // depending on how generic client behaves
// 3. No auth methods (user needs setup)
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [],
});
// 4. Mock session creation success
mockCreateSessionAndUpdateCookie.mockResolvedValue({
session: {
id: "new-session-id",
factors: { user: { id: "user-1", loginName: "[email protected]", organizationId: "org-1" } },
},
});
const result = await sendVerification({
userId: "user-1",
code: "123456",
isInvite: false,
});
// Expect redirect to authenticator setup
expect(result).toEqual({
redirect: "/authenticator/set?sessionId=new-session-id&loginName=test%40example.com",
});
// Verify getSession was called (and failed, but we recovered)
expect(mockGetSession).toHaveBeenCalled();
// Verify creation was attempted
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
});
});
+11 -8
View File
@@ -90,18 +90,20 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
const sessionCookie = await getSessionCookieByLoginName({
loginName: "loginName" in command ? command.loginName : user.preferredLoginName,
organization: command.organization,
}).catch((error) => {
console.warn("Ignored error:", error); // checked later
});
if (sessionCookie) {
session = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token }).then(
(response) => {
session = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token })
.then((response) => {
if (response?.session) {
return response.session;
}
},
);
})
.catch((error) => {
// user session is not found, so we create a new one
console.warn("[verify] user session is not found, so we create a new one", error);
return undefined;
});
}
// load auth methods for user
@@ -113,7 +115,7 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
// if no authmethods are found on the user, redirect to set one up
if (authMethodResponse && authMethodResponse.authMethodTypes && authMethodResponse.authMethodTypes.length == 0) {
if (!sessionCookie) {
if (!session) {
const checks = create(ChecksSchema, {
user: {
search: {
@@ -123,10 +125,11 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
},
});
session = await createSessionAndUpdateCookie({
const result = await createSessionAndUpdateCookie({
checks,
requestId: command.requestId,
});
session = result.session;
}
if (!session) {
+4
View File
@@ -25,6 +25,10 @@ export async function loadMostRecentSession({
organization: sessionParams.organization,
});
if (!recent) {
return undefined;
}
return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token }).then(
(resp: GetSessionResponse) => resp.session,
);
+4 -2
View File
@@ -197,19 +197,21 @@ export async function getPasswordComplexitySettings({
return useCache ? cacheWrapper(callback) : callback;
}
export async function createSessionFromChecks({
export async function createSessionFromChecksAndChallenges({
serviceConfig,
checks,
challenges,
lifetime,
}: WithServiceConfig<{
checks: Checks;
challenges?: RequestChallenges;
lifetime: Duration;
}>) {
const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig);
const userAgent = await getUserAgent();
return sessionService.createSession({ checks, lifetime, userAgent }, {});
return sessionService.createSession({ ...{ checks, lifetime, userAgent }, ...(challenges ? { challenges } : {}) }, {});
}
export async function createSessionForUserIdAndIdpIntent({
+5
View File
@@ -6,6 +6,11 @@
"changeset": "changeset",
"clean:all": "pnpm run clean && rm -rf .nx node_modules"
},
"pnpm": {
"overrides": {
"csstype": "3.1.3"
}
},
"devDependencies": {
"@changesets/cli": "^2.29.8",
"nx": "21.6.1",
+2556 -2474
View File
File diff suppressed because it is too large Load Diff