mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
co-authored by
Livio Spring
parent
e72203ca6d
commit
26f8238499
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "ユーザーがシステムに見つかりません"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,9 @@
|
||||
"couldNotLoadSession": "Не вдалося завантажити сеанс",
|
||||
"couldNotLoadAuthMethods": "Не вдалося завантажити методи автентифікації",
|
||||
"failedPrecondition": "Не виконано передумову",
|
||||
"sessionNotValid": "Сеанс недійсний"
|
||||
"sessionNotValid": "Сеанс недійсний",
|
||||
"passwordVerificationMissing": "Відсутня перевірка пароля.",
|
||||
"passwordVerificationTooOld": "Перевірка пароля застаріла. Будь ласка, перевірте пароль ще раз."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -406,7 +408,8 @@
|
||||
"userAlreadyVerified": "Користувача вже підтверджено!",
|
||||
"couldNotResendInvite": "Не вдалося повторно надіслати запрошення",
|
||||
"inviteSendFailed": "Не вдалося надіслати лист-запрошення",
|
||||
"emailSendFailed": "Не вдалося надіслати лист підтвердження"
|
||||
"emailSendFailed": "Не вдалося надіслати лист підтвердження",
|
||||
"contextMissing": "Сталася помилка. Будь ласка, спробуйте ще раз."
|
||||
}
|
||||
},
|
||||
"authenticator": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const UNKNOWN_USER_ID = "000000000000000000";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2556
-2474
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user