Merge branch 'main' into translation

This commit is contained in:
Max Peintner
2024-12-11 09:36:24 +01:00
committed by GitHub
52 changed files with 1623 additions and 573 deletions

View File

@@ -49,6 +49,7 @@ describe("login", () => {
data: {
settings: {
passkeysType: 1,
allowUsernamePassword: true,
},
},
});

View File

@@ -389,7 +389,10 @@ In future, self service options to jump to are shown below, like:
## Currently NOT Supported
- loginSettings.disableLoginWithEmail
- loginSettings.disableLoginWithPhone
- loginSettings.allowExternalIdp - this will be deprecated with the new login as it can be determined by the available IDPs
- loginSettings.forceMfaLocalOnly
Timebased features like the multifactor init prompt or password expiry, are not supported due to a current limitation in the API. Lockout settings which keeps track of the password retries, will also be implemented in a later stage.
- Lockout Settings
- Password Expiry Settings
- Login Settings: multifactor init prompt
- forceMFA on login settings is not checked for IDPs
- disablePhone / disableEmail from loginSettings will be implemented right after https://github.com/zitadel/zitadel/issues/9016 is merged

View File

@@ -74,6 +74,10 @@ export default async function Page(props: {
});
}
if (!sessionWithData) {
return <Alert>{tError("unknownContext")}</Alert>;
}
const branding = await getBrandingSettings(
sessionWithData.factors?.user?.organizationId,
);
@@ -82,22 +86,34 @@ export default async function Page(props: {
sessionWithData.factors?.user?.organizationId,
);
/* - TODO: Implement after https://github.com/zitadel/zitadel/issues/8981 */
// const identityProviders = await getActiveIdentityProviders(
// sessionWithData.factors?.user?.organizationId,
// ).then((resp) => {
// return resp.identityProviders;
// });
const params = new URLSearchParams({
initial: "true", // defines that a code is not required and is therefore not shown in the UI
});
if (loginName) {
params.set("loginName", loginName);
if (sessionWithData.factors?.user?.loginName) {
params.set("loginName", sessionWithData.factors?.user?.loginName);
}
if (organization) {
params.set("organization", organization);
if (sessionWithData.factors?.user?.organizationId) {
params.set("organization", sessionWithData.factors?.user?.organizationId);
}
if (authRequestId) {
params.set("authRequestId", authRequestId);
}
const host = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
@@ -105,18 +121,14 @@ export default async function Page(props: {
<p className="ztdl-p">{t("description")}</p>
{sessionWithData && (
<UserAvatar
loginName={loginName ?? sessionWithData.factors?.user?.loginName}
displayName={sessionWithData.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
<UserAvatar
loginName={sessionWithData.factors?.user?.loginName}
displayName={sessionWithData.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
{!(loginName || sessionId) && <Alert>{tError("unknownContext")}</Alert>}
{loginSettings && sessionWithData && (
{loginSettings && (
<ChooseAuthenticatorToSetup
authMethods={sessionWithData.authMethods}
loginSettings={loginSettings}
@@ -124,6 +136,22 @@ export default async function Page(props: {
></ChooseAuthenticatorToSetup>
)}
{/* - TODO: Implement after https://github.com/zitadel/zitadel/issues/8981 */}
{/* <p className="ztdl-p text-center">
or sign in with an Identity Provider
</p>
{loginSettings?.allowExternalIdp && identityProviders && (
<SignInWithIdp
host={host}
identityProviders={identityProviders}
authRequestId={authRequestId}
organization={sessionWithData.factors?.user?.organizationId}
linkOnly={true} // tell the callback function to just link the IDP and not login, to get an error when user is already available
></SignInWithIdp>
)} */}
<div className="mt-8 flex w-full flex-row items-center">
<BackButton />
<span className="flex-grow"></span>

View File

@@ -37,7 +37,7 @@ export default async function Page(props: {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "idp" });
const { id, token, authRequestId, organization } = searchParams;
const { id, token, authRequestId, organization, link } = searchParams;
const { provider } = params;
const branding = await getBrandingSettings(organization);
@@ -50,7 +50,8 @@ export default async function Page(props: {
const { idpInformation, userId } = intent;
if (userId) {
// sign in user. If user should be linked continue
if (userId && !link) {
// TODO: update user if idp.options.isAutoUpdate is true
return (

View File

@@ -24,10 +24,6 @@ export default async function Page(props: {
const identityProviders = await getIdentityProviders(organization);
const host = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
const branding = await getBrandingSettings(organization);
return (
@@ -38,7 +34,6 @@ export default async function Page(props: {
{identityProviders && (
<SignInWithIdp
host={host}
identityProviders={identityProviders}
authRequestId={authRequestId}
organization={organization}

View File

@@ -2,23 +2,14 @@ import { DynamicTheme } from "@/components/dynamic-theme";
import { SignInWithIdp } from "@/components/sign-in-with-idp";
import { UsernameForm } from "@/components/username-form";
import {
getActiveIdentityProviders,
getBrandingSettings,
getDefaultOrg,
getLoginSettings,
settingsService,
} from "@/lib/zitadel";
import { makeReqCtx } from "@zitadel/client/v2";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { getLocale, getTranslations } from "next-intl/server";
function getIdentityProviders(orgId?: string) {
return settingsService
.getActiveIdentityProviders({ ctx: makeReqCtx(orgId) }, {})
.then((resp) => {
return resp.identityProviders;
});
}
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}) {
@@ -39,17 +30,15 @@ export default async function Page(props: {
}
}
const host = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
const loginSettings = await getLoginSettings(
organization ?? defaultOrganization,
);
const identityProviders = await getIdentityProviders(
const identityProviders = await getActiveIdentityProviders(
organization ?? defaultOrganization,
);
).then((resp) => {
return resp.identityProviders;
});
const branding = await getBrandingSettings(
organization ?? defaultOrganization,
@@ -68,9 +57,8 @@ export default async function Page(props: {
submit={submit}
allowRegister={!!loginSettings?.allowRegister}
>
{identityProviders && process.env.ZITADEL_API_URL && (
{identityProviders && (
<SignInWithIdp
host={host}
identityProviders={identityProviders}
authRequestId={authRequestId}
organization={organization ?? defaultOrganization} // use the organization from the searchParams here otherwise fallback to the default organization

View File

@@ -2,9 +2,15 @@ import { Alert } from "@/components/alert";
import { DynamicTheme } from "@/components/dynamic-theme";
import { LoginOTP } from "@/components/login-otp";
import { UserAvatar } from "@/components/user-avatar";
import { getSessionCookieById } from "@/lib/cookies";
import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings, getLoginSettings } from "@/lib/zitadel";
import {
getBrandingSettings,
getLoginSettings,
getSession,
} from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
@@ -16,19 +22,44 @@ export default async function Page(props: {
const t = await getTranslations({ locale, namespace: "otp" });
const tError = await getTranslations({ locale, namespace: "error" });
const { loginName, authRequestId, sessionId, organization, code, submit } =
searchParams;
const {
loginName, // send from password page
userId, // send from email link
authRequestId,
sessionId,
organization,
code,
submit,
} = searchParams;
const { method } = params;
const session = await loadMostRecentSession({
loginName,
organization,
});
const session = sessionId
? await loadSessionById(sessionId, organization)
: await loadMostRecentSession({ loginName, organization });
const branding = await getBrandingSettings(organization);
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({
sessionId: recent.id,
sessionToken: recent.token,
}).then((response) => {
if (response?.session) {
return response.session;
}
});
}
const loginSettings = await getLoginSettings(organization);
// email links do not come with organization, thus we need to use the session's organization
const branding = await getBrandingSettings(
organization ?? session?.factors?.user?.organizationId,
);
const loginSettings = await getLoginSettings(
organization ?? session?.factors?.user?.organizationId,
);
const host = (await headers()).get("host");
return (
<DynamicTheme branding={branding}>
@@ -59,14 +90,18 @@ export default async function Page(props: {
></UserAvatar>
)}
{method && (
{method && session && (
<LoginOTP
loginName={loginName}
loginName={loginName ?? session.factors?.user?.loginName}
sessionId={sessionId}
authRequestId={authRequestId}
organization={organization}
organization={
organization ?? session?.factors?.user?.organizationId
}
method={method}
loginSettings={loginSettings}
host={host}
code={code}
></LoginOTP>
)}
</div>

View File

@@ -1,14 +1,17 @@
import { getAllSessions } from "@/lib/cookies";
import { idpTypeToSlug } from "@/lib/idp";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
import {
createCallback,
getActiveIdentityProviders,
getAuthRequest,
getLoginSettings,
getOrgsByDomain,
listAuthenticationMethodTypes,
listSessions,
startIdentityProviderFlow,
} from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { create, timestampDate } from "@zitadel/client";
import {
AuthRequest,
Prompt,
@@ -18,6 +21,7 @@ import {
SessionSchema,
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
@@ -36,23 +40,143 @@ const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/;
const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/; // TODO: check regex for all domain character options
const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/;
function findSession(
sessions: Session[],
authRequest: AuthRequest,
): Session | undefined {
if (authRequest.hintUserId) {
console.log(`find session for hintUserId: ${authRequest.hintUserId}`);
return sessions.find((s) => s.factors?.user?.id === authRequest.hintUserId);
/**
* mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.)
* to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId);
**/
async function isSessionValid(session: Session): Promise<boolean> {
// session can't be checked without user
if (!session.factors?.user) {
console.warn("Session has no user");
return false;
}
if (authRequest.loginHint) {
console.log(`find session for loginHint: ${authRequest.loginHint}`);
return sessions.find(
(s) => s.factors?.user?.loginName === authRequest.loginHint,
let mfaValid = true;
const authMethodTypes = await listAuthenticationMethodTypes(
session.factors.user.id,
);
const authMethods = authMethodTypes.authMethodTypes;
if (authMethods && authMethods.includes(AuthenticationMethodType.TOTP)) {
mfaValid = !!session.factors.totp?.verifiedAt;
if (!mfaValid) {
console.warn(
"Session has no valid totpEmail factor",
session.factors.totp?.verifiedAt,
);
}
} else if (
authMethods &&
authMethods.includes(AuthenticationMethodType.OTP_EMAIL)
) {
mfaValid = !!session.factors.otpEmail?.verifiedAt;
if (!mfaValid) {
console.warn(
"Session has no valid otpEmail factor",
session.factors.otpEmail?.verifiedAt,
);
}
} else if (
authMethods &&
authMethods.includes(AuthenticationMethodType.OTP_SMS)
) {
mfaValid = !!session.factors.otpSms?.verifiedAt;
if (!mfaValid) {
console.warn(
"Session has no valid otpSms factor",
session.factors.otpSms?.verifiedAt,
);
}
} else if (
authMethods &&
authMethods.includes(AuthenticationMethodType.U2F)
) {
mfaValid = !!session.factors.webAuthN?.verifiedAt;
if (!mfaValid) {
console.warn(
"Session has no valid u2f factor",
session.factors.webAuthN?.verifiedAt,
);
}
} else {
// only check settings if no auth methods are available, as this would require a setup
const loginSettings = await getLoginSettings(
session.factors?.user?.organizationId,
);
if (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) {
const otpEmail = session.factors.otpEmail?.verifiedAt;
const otpSms = session.factors.otpSms?.verifiedAt;
const totp = session.factors.totp?.verifiedAt;
const webAuthN = session.factors.webAuthN?.verifiedAt;
// must have one single check
mfaValid = !!(otpEmail || otpSms || totp || webAuthN);
if (!mfaValid) {
console.warn(
"Session has no valid multifactor",
JSON.stringify(session.factors),
);
}
} else {
mfaValid = true;
}
}
const validPassword = session?.factors?.password?.verifiedAt;
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
const validIDP = session?.factors?.intent?.verifiedAt;
const stillValid = session.expirationDate
? timestampDate(session.expirationDate).getTime() > new Date().getTime()
: true;
if (!stillValid) {
console.warn(
"Session is expired",
session.expirationDate
? timestampDate(session.expirationDate).toDateString()
: "no expiration date",
);
}
if (sessions.length) {
return sessions[0];
const validChecks = !!(validPassword || validPasskey || validIDP);
return stillValid && validChecks && mfaValid;
}
async function findValidSession(
sessions: Session[],
authRequest: AuthRequest,
): Promise<Session | undefined> {
const sessionsWithHint = sessions.filter((s) => {
if (authRequest.hintUserId) {
return s.factors?.user?.id === authRequest.hintUserId;
}
if (authRequest.loginHint) {
return s.factors?.user?.loginName === authRequest.loginHint;
}
return true;
});
if (sessionsWithHint.length === 0) {
return undefined;
}
// sort by change date descending
sessionsWithHint.sort((a, b) => {
const dateA = a.changeDate ? timestampDate(a.changeDate).getTime() : 0;
const dateB = b.changeDate ? timestampDate(b.changeDate).getTime() : 0;
return dateB - dateA;
});
// return the first valid session according to settings
for (const session of sessionsWithHint) {
if (await isSessionValid(session)) {
return session;
}
}
return undefined;
}
@@ -74,22 +198,34 @@ export async function GET(request: NextRequest) {
sessions = await loadSessions(ids);
}
/**
* TODO: before automatically redirecting to the callbackUrl, check if the session is still valid
* possible scenaio:
* mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.)
* to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId);
**/
if (authRequestId && sessionId) {
console.log(
`Login with session: ${sessionId} and authRequest: ${authRequestId}`,
);
let selectedSession = sessions.find((s) => s.id === sessionId);
const selectedSession = sessions.find((s) => s.id === sessionId);
if (selectedSession && selectedSession.id) {
console.log(`Found session ${selectedSession.id}`);
const isValid = await isSessionValid(selectedSession);
if (!isValid && selectedSession.factors?.user) {
// if the session is not valid anymore, we need to redirect the user to re-authenticate
const command: SendLoginnameCommand = {
loginName: selectedSession.factors.user?.loginName,
organization: selectedSession.factors?.user?.organizationId,
authRequestId: authRequestId,
};
const res = await sendLoginname(command);
if (res?.redirect) {
const absoluteUrl = new URL(res.redirect, request.url);
return NextResponse.redirect(absoluteUrl.toString());
}
}
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id,
);
@@ -119,8 +255,41 @@ export async function GET(request: NextRequest) {
{ status: 500 },
);
}
} catch (error) {
return NextResponse.json({ error }, { status: 500 });
} catch (error: unknown) {
// handle already handled gracefully as these could come up if old emails with authRequestId are used (reset password, register emails etc.)
console.error(error);
if (
error &&
typeof error === "object" &&
"code" in error &&
error?.code === 9
) {
const loginSettings = await getLoginSettings(
selectedSession.factors?.user?.organizationId,
);
if (loginSettings?.defaultRedirectUri) {
return NextResponse.redirect(loginSettings.defaultRedirectUri);
}
const signedinUrl = new URL("/signedin", request.url);
if (selectedSession.factors?.user?.loginName) {
signedinUrl.searchParams.set(
"loginName",
selectedSession.factors?.user?.loginName,
);
}
if (selectedSession.factors?.user?.organizationId) {
signedinUrl.searchParams.set(
"organization",
selectedSession.factors?.user?.organizationId,
);
}
return NextResponse.redirect(signedinUrl);
} else {
return NextResponse.json({ error }, { status: 500 });
}
}
}
}
@@ -174,7 +343,7 @@ export async function GET(request: NextRequest) {
const idp = identityProviders.find((idp) => idp.id === idpId);
if (idp) {
const host = request.nextUrl.origin;
const origin = request.nextUrl.origin;
const identityProviderType = identityProviders[0].type;
let provider = idpTypeToSlug(identityProviderType);
@@ -193,10 +362,10 @@ export async function GET(request: NextRequest) {
idpId,
urls: {
successUrl:
`${host}/idp/${provider}/success?` +
`${origin}/idp/${provider}/success?` +
new URLSearchParams(params),
failureUrl:
`${host}/idp/${provider}/failure?` +
`${origin}/idp/${provider}/failure?` +
new URLSearchParams(params),
},
}).then((resp) => {
@@ -225,8 +394,8 @@ export async function GET(request: NextRequest) {
if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) {
const registerUrl = new URL("/register", request.url);
if (authRequest?.id) {
registerUrl.searchParams.set("authRequestId", authRequest?.id);
if (authRequest.id) {
registerUrl.searchParams.set("authRequestId", authRequest.id);
}
if (organization) {
registerUrl.searchParams.set("organization", organization);
@@ -241,10 +410,36 @@ export async function GET(request: NextRequest) {
if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) {
return gotoAccounts();
} else if (authRequest.prompt.includes(Prompt.LOGIN)) {
// if prompt is login
/**
* The login prompt instructs the authentication server to prompt the user for re-authentication, regardless of whether the user is already authenticated
*/
// if a hint is provided, skip loginname page and jump to the next page
if (authRequest.loginHint) {
try {
let command: SendLoginnameCommand = {
loginName: authRequest.loginHint,
authRequestId: authRequest.id,
};
if (organization) {
command = { ...command, organization };
}
const res = await sendLoginname(command);
if (res?.redirect) {
const absoluteUrl = new URL(res.redirect, request.url);
return NextResponse.redirect(absoluteUrl.toString());
}
} catch (error) {
console.error("Failed to execute sendLoginname:", error);
}
}
const loginNameUrl = new URL("/loginname", request.url);
if (authRequest?.id) {
loginNameUrl.searchParams.set("authRequestId", authRequest?.id);
if (authRequest.id) {
loginNameUrl.searchParams.set("authRequestId", authRequest.id);
}
if (authRequest.loginHint) {
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
@@ -254,82 +449,87 @@ export async function GET(request: NextRequest) {
}
return NextResponse.redirect(loginNameUrl);
} else if (authRequest.prompt.includes(Prompt.NONE)) {
// NONE prompt - silent authentication
/**
* With an OIDC none prompt, the authentication server must not display any authentication or consent user interface pages.
* This means that the user should not be prompted to enter their password again.
* Instead, the server attempts to silently authenticate the user using an existing session or other authentication mechanisms that do not require user interaction
**/
const selectedSession = await findValidSession(sessions, authRequest);
let selectedSession = findSession(sessions, authRequest);
if (selectedSession && selectedSession.id) {
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id,
);
if (cookie && cookie.id && cookie.token) {
const session = {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
const { callbackUrl } = await createCallback(
create(CreateCallbackRequestSchema, {
authRequestId,
callbackKind: {
case: "session",
value: create(SessionSchema, session),
},
}),
);
return NextResponse.redirect(callbackUrl);
} else {
return NextResponse.json(
{ error: "No active session found" },
{ status: 400 }, // TODO: check for correct status code
);
}
} else {
if (!selectedSession || !selectedSession.id) {
return NextResponse.json(
{ error: "No active session found" },
{ status: 400 }, // TODO: check for correct status code
{ status: 400 },
);
}
} else {
// check for loginHint, userId hint sessions
let selectedSession = findSession(sessions, authRequest);
if (selectedSession && selectedSession.id) {
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id,
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession.id,
);
if (!cookie || !cookie.id || !cookie.token) {
return NextResponse.json(
{ error: "No active session found" },
{ status: 400 },
);
}
if (cookie && cookie.id && cookie.token) {
const session = {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
try {
const { callbackUrl } = await createCallback(
create(CreateCallbackRequestSchema, {
authRequestId,
callbackKind: {
case: "session",
value: create(SessionSchema, session),
},
}),
);
if (callbackUrl) {
return NextResponse.redirect(callbackUrl);
} else {
console.log(
"could not create callback, redirect user to choose other account",
);
return gotoAccounts();
}
} catch (error) {
console.error(error);
return gotoAccounts();
}
const session = {
sessionId: cookie.id,
sessionToken: cookie.token,
};
const { callbackUrl } = await createCallback(
create(CreateCallbackRequestSchema, {
authRequestId,
callbackKind: {
case: "session",
value: create(SessionSchema, session),
},
}),
);
return NextResponse.redirect(callbackUrl);
} else {
// check for loginHint, userId hint and valid sessions
let selectedSession = await findValidSession(sessions, authRequest);
if (!selectedSession || !selectedSession.id) {
return gotoAccounts();
}
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession.id,
);
if (!cookie || !cookie.id || !cookie.token) {
return gotoAccounts();
}
const session = {
sessionId: cookie.id,
sessionToken: cookie.token,
};
try {
const { callbackUrl } = await createCallback(
create(CreateCallbackRequestSchema, {
authRequestId,
callbackKind: {
case: "session",
value: create(SessionSchema, session),
},
}),
);
if (callbackUrl) {
return NextResponse.redirect(callbackUrl);
} else {
console.log(
"could not create callback, redirect user to choose other account",
);
return gotoAccounts();
}
} else {
} catch (error) {
console.error(error);
return gotoAccounts();
}
}

View File

@@ -12,6 +12,7 @@ import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert";
@@ -44,6 +45,7 @@ export function ChangePasswordForm({
organization,
}: Props) {
const t = useTranslations("password");
const router = useRouter();
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
mode: "onBlur",
@@ -107,6 +109,14 @@ export function ChangePasswordForm({
return;
}
if (
passwordResponse &&
"redirect" in passwordResponse &&
passwordResponse.redirect
) {
return router.push(passwordResponse.redirect);
}
return;
}

View File

@@ -25,6 +25,7 @@ type Props = {
method: string;
code?: string;
loginSettings?: LoginSettings;
host: string | null;
};
type Inputs = {
@@ -39,6 +40,7 @@ export function LoginOTP({
method,
code,
loginSettings,
host,
}: Props) {
const t = useTranslations("otp");
@@ -57,7 +59,7 @@ export function LoginOTP({
});
useEffect(() => {
if (!initialized.current && ["email", "sms"].includes(method)) {
if (!initialized.current && ["email", "sms"].includes(method) && !code) {
initialized.current = true;
setLoading(true);
updateSessionForOTPChallenge()
@@ -76,13 +78,24 @@ export function LoginOTP({
if (method === "email") {
challenges = create(RequestChallengesSchema, {
otpEmail: { deliveryType: { case: "sendCode", value: {} } },
otpEmail: {
deliveryType: {
case: "sendCode",
value: host
? {
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}/otp/${method}?code={{.Code}}&userId={{.UserID}}&sessionId={{.SessionID}}` +
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
}
: {},
},
},
});
}
if (method === "sms") {
challenges = create(RequestChallengesSchema, {
otpSms: { returnCode: true },
otpSms: {},
});
}
@@ -94,14 +107,19 @@ export function LoginOTP({
challenges,
authRequestId,
})
.catch((error) => {
setError(error.message ?? "Could not request OTP challenge");
.catch(() => {
setError("Could not request OTP challenge");
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
return response;
}
@@ -154,12 +172,21 @@ export function LoginOTP({
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
return response;
}
function setCodeAndContinue(values: Inputs, organization?: string) {
return submitCode(values, organization).then(async (response) => {
if (response) {
if (response && "sessionId" in response) {
setLoading(true);
// Wait for 2 seconds to avoid eventual consistency issues with an OTP code being verified in the /login endpoint
await new Promise((resolve) => setTimeout(resolve, 2000));
const url =
authRequestId && response.sessionId
? await getNextUrl(
@@ -180,6 +207,7 @@ export function LoginOTP({
)
: null;
setLoading(false);
if (url) {
router.push(url);
}
@@ -198,6 +226,7 @@ export function LoginOTP({
<button
aria-label="Resend OTP Code"
disabled={loading}
type="button"
className="ml-4 text-primary-light-500 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 hover:text-primary-light-400 cursor-pointer disabled:cursor-default disabled:text-gray-400 dark:disabled:text-gray-700"
onClick={() => {
setLoading(true);
@@ -210,6 +239,7 @@ export function LoginOTP({
setLoading(false);
});
}}
data-testid="resend-button"
>
{t("verify.resendCode")}
</button>
@@ -222,11 +252,12 @@ export function LoginOTP({
{...register("code", { required: "This field is required" })}
label="Code"
autoComplete="one-time-code"
data-testid="code-text-input"
/>
</div>
{error && (
<div className="py-4">
<div className="py-4" data-testid="error">
<Alert>{error}</Alert>
</div>
)}

View File

@@ -110,6 +110,11 @@ export function LoginPasskey({
setLoading(false);
});
if (session && "error" in session && session.error) {
setError(session.error);
return;
}
return session;
}
@@ -132,6 +137,11 @@ export function LoginPasskey({
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
return response;
}

View File

@@ -59,7 +59,6 @@ export function PasswordForm({
password: { password: values.password },
}),
authRequestId,
forceMfa: loginSettings?.forceMfa,
})
.catch(() => {
setError("Could not verify password");
@@ -87,6 +86,7 @@ export function PasswordForm({
const response = await resetPassword({
loginName,
organization,
authRequestId,
})
.catch(() => {
setError("Could not reset password");
@@ -134,6 +134,7 @@ export function PasswordForm({
onClick={() => resetPasswordAndContinue()}
type="button"
disabled={loading}
data-testid="reset-button"
>
{t("verify.resetPassword")}
</button>

View File

@@ -16,12 +16,14 @@ export function isSessionValid(session: Partial<Session>): {
} {
const validPassword = session?.factors?.password?.verifiedAt;
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
const validIDP = session?.factors?.intent?.verifiedAt;
const stillValid = session.expirationDate
? timestampDate(session.expirationDate) > new Date()
: true;
const verifiedAt = validPassword || validPasskey;
const valid = !!((validPassword || validPasskey) && stillValid);
const verifiedAt = validPassword || validPasskey || validIDP;
const valid = !!((validPassword || validPasskey || validIDP) && stillValid);
return { valid, verifiedAt };
}
@@ -63,10 +65,14 @@ export function SessionItem({
<button
onClick={async () => {
if (valid && session?.factors?.user) {
return continueWithSession({
const resp = await continueWithSession({
...session,
authRequestId: authRequestId,
});
if (resp?.redirect) {
return router.push(resp.redirect);
}
} else if (session.factors?.user) {
setLoading(true);
const res = await sendLoginname({
@@ -102,15 +108,23 @@ export function SessionItem({
/>
</div>
<div className="flex flex-col overflow-hidden">
<div className="flex flex-col items-start overflow-hidden">
<span className="">{session.factors?.user?.displayName}</span>
<span className="text-xs opacity-80 text-ellipsis">
{session.factors?.user?.loginName}
</span>
{valid && (
{valid ? (
<span className="text-xs opacity-80 text-ellipsis">
{verifiedAt && moment(timestampDate(verifiedAt)).fromNow()}
</span>
) : (
verifiedAt && (
<span className="text-xs opacity-80 text-ellipsis">
expired{" "}
{session.expirationDate &&
moment(timestampDate(session.expirationDate)).fromNow()}
</span>
)
)}
</div>
@@ -126,6 +140,7 @@ export function SessionItem({
className="hidden group-hover:block h-5 w-5 transition-all opacity-50 hover:opacity-100"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearSession(session.id).then(() => {
reload();
});

View File

@@ -1,6 +1,6 @@
"use client";
import { timestampMs } from "@zitadel/client";
import { timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { useTranslations } from "next-intl";
import { useState } from "react";
@@ -12,14 +12,6 @@ type Props = {
authRequestId?: string;
};
function sortFc(a: Session, b: Session) {
if (a.changeDate && b.changeDate) {
return timestampMs(a.changeDate) - timestampMs(b.changeDate);
} else {
return 0;
}
}
export function SessionsList({ sessions, authRequestId }: Props) {
const t = useTranslations("accounts");
const [list, setList] = useState<Session[]>(sessions);
@@ -27,7 +19,17 @@ export function SessionsList({ sessions, authRequestId }: Props) {
<div className="flex flex-col space-y-2">
{list
.filter((session) => session?.factors?.user?.loginName)
.sort(sortFc)
// sort by change date descending
.sort((a, b) => {
const dateA = a.changeDate
? timestampDate(a.changeDate).getTime()
: 0;
const dateB = b.changeDate
? timestampDate(b.changeDate).getTime()
: 0;
return dateB - dateA;
})
// TODO: add sorting to move invalid sessions to the bottom
.map((session, index) => {
return (
<SessionItem

View File

@@ -6,7 +6,11 @@ import {
symbolValidator,
upperCaseValidator,
} from "@/helpers/validators";
import { changePassword, sendPassword } from "@/lib/server/password";
import {
changePassword,
resetPassword,
sendPassword,
} from "@/lib/server/password";
import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
@@ -62,6 +66,29 @@ export function SetPasswordForm({
const router = useRouter();
async function resendCode() {
setError("");
setLoading(true);
const response = await resetPassword({
loginName,
organization,
authRequestId,
})
.catch(() => {
setError("Could not reset password");
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response) {
setError(response.error);
return;
}
}
async function submitPassword(values: Inputs) {
setLoading(true);
let payload: { userId: string; password: string; code?: string } = {
@@ -176,11 +203,17 @@ export function SetPasswordForm({
label="Code"
autoComplete="one-time-code"
error={errors.code?.message as string}
data-testid="code-text-input"
/>
</div>
<div className="ml-4 mb-1">
<Button variant={ButtonVariants.Secondary}>
<Button
variant={ButtonVariants.Secondary}
data-testid="resend-button"
onClick={() => resendCode()}
disabled={loading}
>
{t("set.resend")}
</Button>
</div>
@@ -196,6 +229,7 @@ export function SetPasswordForm({
})}
label="New Password"
error={errors.password?.message as string}
data-testid="password-text-input"
/>
</div>
<div className="">
@@ -208,6 +242,7 @@ export function SetPasswordForm({
})}
label="Confirm Password"
error={errors.confirmPassword?.message as string}
data-testid="password-confirm-text-input"
/>
</div>
</div>

View File

@@ -18,17 +18,17 @@ import { SignInWithGoogle } from "./idps/sign-in-with-google";
export interface SignInWithIDPProps {
children?: ReactNode;
host: string;
identityProviders: IdentityProvider[];
authRequestId?: string;
organization?: string;
linkOnly?: boolean;
}
export function SignInWithIdp({
host,
identityProviders,
authRequestId,
organization,
linkOnly,
}: SignInWithIDPProps) {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
@@ -39,6 +39,10 @@ export function SignInWithIdp({
const params = new URLSearchParams();
if (linkOnly) {
params.set("link", "true");
}
if (authRequestId) {
params.set("authRequestId", authRequestId);
}
@@ -49,10 +53,8 @@ export function SignInWithIdp({
const response = await startIDPFlow({
idpId,
successUrl:
`${host}/idp/${provider}/success?` + new URLSearchParams(params),
failureUrl:
`${host}/idp/${provider}/failure?` + new URLSearchParams(params),
successUrl: `/idp/${provider}/success?` + new URLSearchParams(params),
failureUrl: `/idp/${provider}/failure?` + new URLSearchParams(params),
})
.catch(() => {
setError("Could not start IDP flow");
@@ -62,6 +64,11 @@ export function SignInWithIdp({
setLoading(false);
});
if (response && "error" in response && response?.error) {
setError(response.error);
return;
}
if (response && "redirect" in response && response?.redirect) {
return router.push(response.redirect);
}
@@ -70,121 +77,136 @@ export function SignInWithIdp({
return (
<div className="flex flex-col w-full space-y-2 text-sm">
{identityProviders &&
identityProviders.map((idp, i) => {
switch (idp.type) {
case IdentityProviderType.APPLE:
return (
<SignInWithApple
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(idp.id, idpTypeToSlug(IdentityProviderType.APPLE))
}
></SignInWithApple>
);
case IdentityProviderType.OAUTH:
return (
<SignInWithGeneric
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(idp.id, idpTypeToSlug(IdentityProviderType.OAUTH))
}
></SignInWithGeneric>
);
case IdentityProviderType.OIDC:
return (
<SignInWithGeneric
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(idp.id, idpTypeToSlug(IdentityProviderType.OIDC))
}
></SignInWithGeneric>
);
case IdentityProviderType.GITHUB:
return (
<SignInWithGithub
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITHUB),
)
}
></SignInWithGithub>
);
case IdentityProviderType.GITHUB_ES:
return (
<SignInWithGithub
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITHUB_ES),
)
}
></SignInWithGithub>
);
case IdentityProviderType.AZURE_AD:
return (
<SignInWithAzureAd
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.AZURE_AD),
)
}
></SignInWithAzureAd>
);
case IdentityProviderType.GOOGLE:
return (
<SignInWithGoogle
key={`idp-${i}`}
e2e="google"
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GOOGLE),
)
}
></SignInWithGoogle>
);
case IdentityProviderType.GITLAB:
return (
<SignInWithGitlab
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITLAB),
)
}
></SignInWithGitlab>
);
case IdentityProviderType.GITLAB_SELF_HOSTED:
return (
<SignInWithGitlab
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITLAB_SELF_HOSTED),
)
}
></SignInWithGitlab>
);
default:
return null;
}
})}
identityProviders
/* - TODO: Implement after https://github.com/zitadel/zitadel/issues/8981 */
// .filter((idp) =>
// linkOnly ? idp.config?.options.isLinkingAllowed : true,
// )
.map((idp, i) => {
switch (idp.type) {
case IdentityProviderType.APPLE:
return (
<SignInWithApple
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.APPLE),
)
}
></SignInWithApple>
);
case IdentityProviderType.OAUTH:
return (
<SignInWithGeneric
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.OAUTH),
)
}
></SignInWithGeneric>
);
case IdentityProviderType.OIDC:
return (
<SignInWithGeneric
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.OIDC),
)
}
></SignInWithGeneric>
);
case IdentityProviderType.GITHUB:
return (
<SignInWithGithub
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITHUB),
)
}
></SignInWithGithub>
);
case IdentityProviderType.GITHUB_ES:
return (
<SignInWithGithub
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITHUB_ES),
)
}
></SignInWithGithub>
);
case IdentityProviderType.AZURE_AD:
return (
<SignInWithAzureAd
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.AZURE_AD),
)
}
></SignInWithAzureAd>
);
case IdentityProviderType.GOOGLE:
return (
<SignInWithGoogle
key={`idp-${i}`}
e2e="google"
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GOOGLE),
)
}
></SignInWithGoogle>
);
case IdentityProviderType.GITLAB:
return (
<SignInWithGitlab
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITLAB),
)
}
></SignInWithGitlab>
);
case IdentityProviderType.GITLAB_SELF_HOSTED:
return (
<SignInWithGitlab
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITLAB_SELF_HOSTED),
)
}
></SignInWithGitlab>
);
default:
return null;
}
})}
{error && (
<div className="py-4">
<Alert>{error}</Alert>

View File

@@ -15,24 +15,29 @@ export async function getNextUrl(
defaultRedirectUri?: string,
): Promise<string> {
if ("sessionId" in command && "authRequestId" in command) {
const url =
`/login?` +
new URLSearchParams({
sessionId: command.sessionId,
authRequest: command.authRequestId,
});
return url;
const params = new URLSearchParams({
sessionId: command.sessionId,
authRequest: command.authRequestId,
});
if (command.organization) {
params.append("organization", command.organization);
}
return `/login?` + params;
}
if (defaultRedirectUri) {
return defaultRedirectUri;
}
const signedInUrl =
`/signedin?` +
new URLSearchParams({
loginName: command.loginName,
});
const params = new URLSearchParams({
loginName: command.loginName,
});
return signedInUrl;
if (command.organization) {
params.append("organization", command.organization);
}
return `/signedin?` + params;
}

View File

@@ -142,7 +142,7 @@ export async function removeSessionFromCookie<T>(
}
}
export async function getMostRecentSessionCookie<T>(): Promise<any> {
export async function getMostRecentSessionCookie<T>(): Promise<Cookie> {
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");

View File

@@ -84,8 +84,8 @@ type resendVerifyEmailCommand = {
export async function resendVerification(command: resendVerifyEmailCommand) {
return command.isInvite
? resendEmailCode(command.userId)
: resendInviteCode(command.userId);
? resendInviteCode(command.userId)
: resendEmailCode(command.userId);
}
export async function sendVerificationRedirectWithoutCheck(command: {

View File

@@ -1,6 +1,7 @@
"use server";
import { startIdentityProviderFlow } from "@/lib/zitadel";
import { headers } from "next/headers";
export type StartIDPFlowCommand = {
idpId: string;
@@ -9,11 +10,17 @@ export type StartIDPFlowCommand = {
};
export async function startIDPFlow(command: StartIDPFlowCommand) {
const host = (await headers()).get("host");
if (!host) {
return { error: "Could not get host" };
}
return startIdentityProviderFlow({
idpId: command.idpId,
urls: {
successUrl: command.successUrl,
failureUrl: command.failureUrl,
successUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${command.successUrl}`,
failureUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${command.failureUrl}`,
},
}).then((response) => {
if (

View File

@@ -2,11 +2,12 @@
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 { headers } from "next/headers";
import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp";
import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import {
getActiveIdentityProviders,
getIDPByID,
@@ -35,6 +36,15 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const loginSettings = await getLoginSettings(command.organization);
const potentialUsers = users.result.filter((u) => {
const human = u.type.case === "human" ? u.type.value : undefined;
return loginSettings?.disableLoginWithEmail
? human?.email?.isVerified && human?.email?.email !== command.loginName
: loginSettings?.disableLoginWithPhone
? human?.phone?.isVerified && human?.phone?.phone !== command.loginName
: true;
});
const redirectUserToSingleIDPIfAvailable = async () => {
const identityProviders = await getActiveIdentityProviders(
command.organization,
@@ -44,6 +54,11 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (identityProviders.length === 1) {
const host = (await headers()).get("host");
if (!host) {
return { error: "Could not get host" };
}
const identityProviderType = identityProviders[0].type;
const provider = idpTypeToSlug(identityProviderType);
@@ -62,9 +77,11 @@ export async function sendLoginname(command: SendLoginnameCommand) {
idpId: identityProviders[0].id,
urls: {
successUrl:
`${host}/idp/${provider}/success?` + new URLSearchParams(params),
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/success?` +
new URLSearchParams(params),
failureUrl:
`${host}/idp/${provider}/failure?` + new URLSearchParams(params),
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/failure?` +
new URLSearchParams(params),
},
});
@@ -81,9 +98,15 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (identityProviders.length === 1) {
const host = (await headers()).get("host");
if (!host) {
return { error: "Could not get host" };
}
const identityProviderId = identityProviders[0].idpId;
const idp = await getIDPByID(identityProviderId);
const idpType = idp?.type;
if (!idp || !idpType) {
@@ -107,9 +130,11 @@ export async function sendLoginname(command: SendLoginnameCommand) {
idpId: idp.id,
urls: {
successUrl:
`${host}/idp/${provider}/success?` + new URLSearchParams(params),
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/success?` +
new URLSearchParams(params),
failureUrl:
`${host}/idp/${provider}/failure?` + new URLSearchParams(params),
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/failure?` +
new URLSearchParams(params),
},
});
@@ -119,8 +144,8 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}
};
if (users.details?.totalResult == BigInt(1) && users.result[0].userId) {
const userId = users.result[0].userId;
if (potentialUsers.length == 1 && potentialUsers[0].userId) {
const userId = potentialUsers[0].userId;
const checks = create(ChecksSchema, {
user: { search: { case: "userId", value: userId } },
@@ -136,24 +161,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
return { error: "Could not create session for user" };
}
if (users.result[0].state === UserState.INITIAL) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName,
initial: "true", // this does not require a code to be set
});
if (command.organization || session.factors?.user?.organizationId) {
params.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
if (command.authRequestId) {
params.append("authRequestid", command.authRequestId);
}
return { redirect: "/password/set?" + params };
// TODO: check if handling of userstate INITIAL is needed
if (potentialUsers[0].state === UserState.INITIAL) {
return { error: "Initial User not supported" };
}
const methods = await listAuthenticationMethodTypes(
@@ -162,9 +172,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (!methods.authMethodTypes || !methods.authMethodTypes.length) {
if (
users.result[0].type.case === "human" &&
users.result[0].type.value.email &&
!users.result[0].type.value.email.isVerified
potentialUsers[0].type.case === "human" &&
potentialUsers[0].type.value.email &&
!potentialUsers[0].type.value.email.isVerified
) {
const paramsVerify = new URLSearchParams({
loginName: session.factors?.user?.loginName,
@@ -209,6 +219,13 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const method = methods.authMethodTypes[0];
switch (method) {
case AuthenticationMethodType.PASSWORD: // user has only password as auth method
if (!loginSettings?.allowUsernamePassword) {
return {
error:
"Username Password not allowed! Contact your administrator for more information.",
};
}
const paramsPassword: any = {
loginName: session.factors?.user?.loginName,
};
@@ -229,6 +246,13 @@ export async function sendLoginname(command: SendLoginnameCommand) {
};
case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
if (loginSettings?.passkeysType === PasskeysType.NOT_ALLOWED) {
return {
error:
"Passkeys not allowed! Contact your administrator for more information.",
};
}
const paramsPasskey: any = { loginName: command.loginName };
if (command.authRequestId) {
paramsPasskey.authRequestId = command.authRequestId;
@@ -262,7 +286,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
} else if (
methods.authMethodTypes.includes(AuthenticationMethodType.IDP)
) {
await redirectUserToIDP(userId);
return redirectUserToIDP(userId);
} else if (
methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)
) {
@@ -288,8 +312,10 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// user not found, check if register is enabled on organization
if (loginSettings?.allowRegister && !loginSettings?.allowUsernamePassword) {
// TODO: do we need to handle login hints for IDPs here?
await redirectUserToSingleIDPIfAvailable();
const resp = await redirectUserToSingleIDPIfAvailable();
if (resp) {
return resp;
}
return { error: "Could not find user" };
} else if (
loginSettings?.allowRegister &&

View File

@@ -27,6 +27,7 @@ import { getSessionCookieByLoginName } from "../cookies";
type ResetPasswordCommand = {
loginName: string;
organization?: string;
authRequestId?: string;
};
export async function resetPassword(command: ResetPasswordCommand) {
@@ -46,7 +47,7 @@ export async function resetPassword(command: ResetPasswordCommand) {
}
const userId = users.result[0].userId;
return passwordReset(userId, host);
return passwordReset(userId, host, command.authRequestId);
}
export type UpdateSessionCommand = {
@@ -54,7 +55,6 @@ export type UpdateSessionCommand = {
organization?: string;
checks: Checks;
authRequestId?: string;
forceMfa?: boolean;
};
export async function sendPassword(command: UpdateSessionCommand) {
@@ -148,6 +148,27 @@ export async function sendPassword(command: UpdateSessionCommand) {
m !== AuthenticationMethodType.PASSKEY,
);
const humanUser = user.type.case === "human" ? user.type.value : undefined;
if (
availableSecondFactors?.length == 0 &&
humanUser?.passwordChangeRequired
) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName,
});
if (command.organization || session.factors?.user?.organizationId) {
params.append("organization", session.factors?.user?.organizationId);
}
if (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
return { redirect: "/password/change?" + params };
}
if (availableSecondFactors?.length == 1) {
const params = new URLSearchParams({
loginName: session.factors?.user.loginName,
@@ -192,24 +213,14 @@ export async function sendPassword(command: UpdateSessionCommand) {
}
return { redirect: `/mfa?` + params };
} else if (user.state === UserState.INITIAL) {
const params = new URLSearchParams({
loginName: session.factors.user.loginName,
});
if (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
if (command.organization || session.factors?.user?.organizationId) {
params.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
return { redirect: `/password/change?` + params };
} else if (command.forceMfa && !availableSecondFactors.length) {
}
// TODO: check if handling of userstate INITIAL is needed
else if (user.state === UserState.INITIAL) {
return { error: "Initial User not supported" };
} else if (
(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) &&
!availableSecondFactors.length
) {
const params = new URLSearchParams({
loginName: session.factors.user.loginName,
force: "true", // this defines if the mfa is forced in the settings

View File

@@ -15,7 +15,6 @@ import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_p
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { getNextUrl } from "../client";
import {
getMostRecentSessionCookie,
@@ -108,7 +107,7 @@ export async function continueWithSession({
)
: null;
if (url) {
return redirect(url);
return { redirect: url };
}
}
@@ -132,21 +131,23 @@ export async function updateSession(options: UpdateSessionCommand) {
challenges,
} = options;
const recentSession = sessionId
? await getSessionCookieById({ sessionId }).catch((error) => {
return Promise.reject(error);
})
? await getSessionCookieById({ sessionId })
: loginName
? await getSessionCookieByLoginName({ loginName, organization }).catch(
(error) => {
return Promise.reject(error);
},
)
: await getMostRecentSessionCookie().catch((error) => {
return Promise.reject(error);
});
? await getSessionCookieByLoginName({ loginName, organization })
: await getMostRecentSessionCookie();
if (!recentSession) {
return {
error: "Could not find session",
};
}
const host = (await headers()).get("host");
if (!host) {
return { error: "Could not get host" };
}
if (
host &&
challenges &&
@@ -174,6 +175,10 @@ export async function updateSession(options: UpdateSessionCommand) {
lifetime,
);
if (!session) {
return { error: "Could not update session" };
}
// if password, check if user has MFA methods
let authMethods;
if (checks && checks.password && session.factors?.user?.id) {

View File

@@ -445,11 +445,6 @@ export async function verifyEmail(userId: string, verificationCode: string) {
);
}
/**
*
* @param userId the id of the user where the email should be set
* @returns the newly set email
*/
export async function resendEmailCode(userId: string) {
return userService.resendEmailCode(
{
@@ -504,7 +499,11 @@ export function createUser(
* @param userId the id of the user where the email should be set
* @returns the newly set email
*/
export async function passwordReset(userId: string, host: string | null) {
export async function passwordReset(
userId: string,
host: string | null,
authRequestId?: string,
) {
let medium = create(SendPasswordResetLinkSchema, {
notificationType: NotificationType.Email,
});
@@ -512,7 +511,9 @@ export async function passwordReset(userId: string, host: string | null) {
if (host) {
medium = {
...medium,
urlTemplate: `${host.includes("localhost") ? "http://" : "https://"}${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}`,
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
};
}