mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-12 08:14:24 +00:00
@@ -394,3 +394,5 @@ Timebased features like the multifactor init prompt or password expiry, are not
|
|||||||
- Lockout Settings
|
- Lockout Settings
|
||||||
- Password Expiry Settings
|
- Password Expiry Settings
|
||||||
- Login Settings: multifactor init prompt
|
- 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
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import { Alert } from "@/components/alert";
|
|||||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||||
import { LoginOTP } from "@/components/login-otp";
|
import { LoginOTP } from "@/components/login-otp";
|
||||||
import { UserAvatar } from "@/components/user-avatar";
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
|
import { getSessionCookieById } from "@/lib/cookies";
|
||||||
import { loadMostRecentSession } from "@/lib/session";
|
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 { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
@@ -17,19 +22,42 @@ export default async function Page(props: {
|
|||||||
const t = await getTranslations({ locale, namespace: "otp" });
|
const t = await getTranslations({ locale, namespace: "otp" });
|
||||||
const tError = await getTranslations({ locale, namespace: "error" });
|
const tError = await getTranslations({ locale, namespace: "error" });
|
||||||
|
|
||||||
const { loginName, authRequestId, sessionId, organization, code, submit } =
|
const {
|
||||||
searchParams;
|
loginName, // send from password page
|
||||||
|
userId, // send from email link
|
||||||
|
authRequestId,
|
||||||
|
sessionId,
|
||||||
|
organization,
|
||||||
|
code,
|
||||||
|
submit,
|
||||||
|
} = searchParams;
|
||||||
|
|
||||||
const { method } = params;
|
const { method } = params;
|
||||||
|
|
||||||
const session = await loadMostRecentSession({
|
const session = sessionId
|
||||||
loginName,
|
? await loadSessionById(sessionId, organization)
|
||||||
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");
|
const host = (await headers()).get("host");
|
||||||
|
|
||||||
@@ -62,12 +90,14 @@ export default async function Page(props: {
|
|||||||
></UserAvatar>
|
></UserAvatar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{method && (
|
{method && session && (
|
||||||
<LoginOTP
|
<LoginOTP
|
||||||
loginName={loginName}
|
loginName={loginName ?? session.factors?.user?.loginName}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
authRequestId={authRequestId}
|
authRequestId={authRequestId}
|
||||||
organization={organization}
|
organization={
|
||||||
|
organization ?? session?.factors?.user?.organizationId
|
||||||
|
}
|
||||||
method={method}
|
method={method}
|
||||||
loginSettings={loginSettings}
|
loginSettings={loginSettings}
|
||||||
host={host}
|
host={host}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { getAllSessions } from "@/lib/cookies";
|
import { getAllSessions } from "@/lib/cookies";
|
||||||
import { idpTypeToSlug } from "@/lib/idp";
|
import { idpTypeToSlug } from "@/lib/idp";
|
||||||
|
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
|
||||||
import {
|
import {
|
||||||
createCallback,
|
createCallback,
|
||||||
getActiveIdentityProviders,
|
getActiveIdentityProviders,
|
||||||
getAuthRequest,
|
getAuthRequest,
|
||||||
|
getLoginSettings,
|
||||||
getOrgsByDomain,
|
getOrgsByDomain,
|
||||||
|
listAuthenticationMethodTypes,
|
||||||
listSessions,
|
listSessions,
|
||||||
startIdentityProviderFlow,
|
startIdentityProviderFlow,
|
||||||
} from "@/lib/zitadel";
|
} from "@/lib/zitadel";
|
||||||
import { create } from "@zitadel/client";
|
import { create, timestampDate } from "@zitadel/client";
|
||||||
import {
|
import {
|
||||||
AuthRequest,
|
AuthRequest,
|
||||||
Prompt,
|
Prompt,
|
||||||
@@ -18,6 +21,7 @@ import {
|
|||||||
SessionSchema,
|
SessionSchema,
|
||||||
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
|
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
|
||||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_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";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 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:(.+)/;
|
const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/;
|
||||||
|
|
||||||
function findSession(
|
/**
|
||||||
sessions: Session[],
|
* mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.)
|
||||||
authRequest: AuthRequest,
|
* to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId);
|
||||||
): Session | undefined {
|
**/
|
||||||
if (authRequest.hintUserId) {
|
async function isSessionValid(session: Session): Promise<boolean> {
|
||||||
console.log(`find session for hintUserId: ${authRequest.hintUserId}`);
|
// session can't be checked without user
|
||||||
return sessions.find((s) => s.factors?.user?.id === authRequest.hintUserId);
|
if (!session.factors?.user) {
|
||||||
|
console.warn("Session has no user");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
if (authRequest.loginHint) {
|
|
||||||
console.log(`find session for loginHint: ${authRequest.loginHint}`);
|
let mfaValid = true;
|
||||||
return sessions.find(
|
|
||||||
(s) => s.factors?.user?.loginName === authRequest.loginHint,
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,22 +198,34 @@ export async function GET(request: NextRequest) {
|
|||||||
sessions = await loadSessions(ids);
|
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) {
|
if (authRequestId && sessionId) {
|
||||||
console.log(
|
console.log(
|
||||||
`Login with session: ${sessionId} and authRequest: ${authRequestId}`,
|
`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) {
|
if (selectedSession && selectedSession.id) {
|
||||||
console.log(`Found session ${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(
|
const cookie = sessionCookies.find(
|
||||||
(cookie) => cookie.id === selectedSession?.id,
|
(cookie) => cookie.id === selectedSession?.id,
|
||||||
);
|
);
|
||||||
@@ -119,8 +255,41 @@ export async function GET(request: NextRequest) {
|
|||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
return NextResponse.json({ error }, { status: 500 });
|
// 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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,8 +394,8 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) {
|
if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) {
|
||||||
const registerUrl = new URL("/register", request.url);
|
const registerUrl = new URL("/register", request.url);
|
||||||
if (authRequest?.id) {
|
if (authRequest.id) {
|
||||||
registerUrl.searchParams.set("authRequestId", authRequest?.id);
|
registerUrl.searchParams.set("authRequestId", authRequest.id);
|
||||||
}
|
}
|
||||||
if (organization) {
|
if (organization) {
|
||||||
registerUrl.searchParams.set("organization", organization);
|
registerUrl.searchParams.set("organization", organization);
|
||||||
@@ -241,10 +410,36 @@ export async function GET(request: NextRequest) {
|
|||||||
if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) {
|
if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) {
|
||||||
return gotoAccounts();
|
return gotoAccounts();
|
||||||
} else if (authRequest.prompt.includes(Prompt.LOGIN)) {
|
} 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);
|
const loginNameUrl = new URL("/loginname", request.url);
|
||||||
if (authRequest?.id) {
|
if (authRequest.id) {
|
||||||
loginNameUrl.searchParams.set("authRequestId", authRequest?.id);
|
loginNameUrl.searchParams.set("authRequestId", authRequest.id);
|
||||||
}
|
}
|
||||||
if (authRequest.loginHint) {
|
if (authRequest.loginHint) {
|
||||||
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
|
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
|
||||||
@@ -254,82 +449,87 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
return NextResponse.redirect(loginNameUrl);
|
return NextResponse.redirect(loginNameUrl);
|
||||||
} else if (authRequest.prompt.includes(Prompt.NONE)) {
|
} 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) {
|
||||||
|
|
||||||
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 {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "No active session found" },
|
{ 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(
|
||||||
const cookie = sessionCookies.find(
|
(cookie) => cookie.id === selectedSession.id,
|
||||||
(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 = {
|
||||||
const session = {
|
sessionId: cookie.id,
|
||||||
sessionId: cookie?.id,
|
sessionToken: cookie.token,
|
||||||
sessionToken: cookie?.token,
|
};
|
||||||
};
|
|
||||||
try {
|
const { callbackUrl } = await createCallback(
|
||||||
const { callbackUrl } = await createCallback(
|
create(CreateCallbackRequestSchema, {
|
||||||
create(CreateCallbackRequestSchema, {
|
authRequestId,
|
||||||
authRequestId,
|
callbackKind: {
|
||||||
callbackKind: {
|
case: "session",
|
||||||
case: "session",
|
value: create(SessionSchema, session),
|
||||||
value: create(SessionSchema, session),
|
},
|
||||||
},
|
}),
|
||||||
}),
|
);
|
||||||
);
|
return NextResponse.redirect(callbackUrl);
|
||||||
if (callbackUrl) {
|
} else {
|
||||||
return NextResponse.redirect(callbackUrl);
|
// check for loginHint, userId hint and valid sessions
|
||||||
} else {
|
let selectedSession = await findValidSession(sessions, authRequest);
|
||||||
console.log(
|
|
||||||
"could not create callback, redirect user to choose other account",
|
if (!selectedSession || !selectedSession.id) {
|
||||||
);
|
return gotoAccounts();
|
||||||
return gotoAccounts();
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
const cookie = sessionCookies.find(
|
||||||
console.error(error);
|
(cookie) => cookie.id === selectedSession.id,
|
||||||
return gotoAccounts();
|
);
|
||||||
}
|
|
||||||
|
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 {
|
} else {
|
||||||
|
console.log(
|
||||||
|
"could not create callback, redirect user to choose other account",
|
||||||
|
);
|
||||||
return gotoAccounts();
|
return gotoAccounts();
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
return gotoAccounts();
|
return gotoAccounts();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { create } from "@zitadel/client";
|
|||||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||||
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FieldValues, useForm } from "react-hook-form";
|
import { FieldValues, useForm } from "react-hook-form";
|
||||||
import { Alert } from "./alert";
|
import { Alert } from "./alert";
|
||||||
@@ -44,6 +45,7 @@ export function ChangePasswordForm({
|
|||||||
organization,
|
organization,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("password");
|
const t = useTranslations("password");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
|
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
|
||||||
mode: "onBlur",
|
mode: "onBlur",
|
||||||
@@ -107,6 +109,14 @@ export function ChangePasswordForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
passwordResponse &&
|
||||||
|
"redirect" in passwordResponse &&
|
||||||
|
passwordResponse.redirect
|
||||||
|
) {
|
||||||
|
return router.push(passwordResponse.redirect);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function LoginOTP({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized.current && ["email", "sms"].includes(method)) {
|
if (!initialized.current && ["email", "sms"].includes(method) && !code) {
|
||||||
initialized.current = true;
|
initialized.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
updateSessionForOTPChallenge()
|
updateSessionForOTPChallenge()
|
||||||
@@ -84,7 +84,7 @@ export function LoginOTP({
|
|||||||
value: host
|
value: host
|
||||||
? {
|
? {
|
||||||
urlTemplate:
|
urlTemplate:
|
||||||
`${host.includes("localhost") ? "http://" : "https://"}${host}/otp/method=${method}?code={{.Code}}&userId={{.UserID}}&sessionId={{.SessionID}}` +
|
`${host.includes("localhost") ? "http://" : "https://"}${host}/otp/${method}?code={{.Code}}&userId={{.UserID}}&sessionId={{.SessionID}}` +
|
||||||
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
|
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
@@ -182,7 +182,11 @@ export function LoginOTP({
|
|||||||
|
|
||||||
function setCodeAndContinue(values: Inputs, organization?: string) {
|
function setCodeAndContinue(values: Inputs, organization?: string) {
|
||||||
return submitCode(values, organization).then(async (response) => {
|
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 =
|
const url =
|
||||||
authRequestId && response.sessionId
|
authRequestId && response.sessionId
|
||||||
? await getNextUrl(
|
? await getNextUrl(
|
||||||
@@ -203,6 +207,7 @@ export function LoginOTP({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
if (url) {
|
if (url) {
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
@@ -221,6 +226,7 @@ export function LoginOTP({
|
|||||||
<button
|
<button
|
||||||
aria-label="Resend OTP Code"
|
aria-label="Resend OTP Code"
|
||||||
disabled={loading}
|
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"
|
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={() => {
|
onClick={() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -65,10 +65,14 @@ export function SessionItem({
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (valid && session?.factors?.user) {
|
if (valid && session?.factors?.user) {
|
||||||
return continueWithSession({
|
const resp = await continueWithSession({
|
||||||
...session,
|
...session,
|
||||||
authRequestId: authRequestId,
|
authRequestId: authRequestId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (resp?.redirect) {
|
||||||
|
return router.push(resp.redirect);
|
||||||
|
}
|
||||||
} else if (session.factors?.user) {
|
} else if (session.factors?.user) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await sendLoginname({
|
const res = await sendLoginname({
|
||||||
@@ -114,11 +118,13 @@ export function SessionItem({
|
|||||||
{verifiedAt && moment(timestampDate(verifiedAt)).fromNow()}
|
{verifiedAt && moment(timestampDate(verifiedAt)).fromNow()}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs opacity-80 text-ellipsis">
|
verifiedAt && (
|
||||||
expired{" "}
|
<span className="text-xs opacity-80 text-ellipsis">
|
||||||
{session.expirationDate &&
|
expired{" "}
|
||||||
moment(timestampDate(session.expirationDate)).fromNow()}
|
{session.expirationDate &&
|
||||||
</span>
|
moment(timestampDate(session.expirationDate)).fromNow()}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,6 +140,7 @@ export function SessionItem({
|
|||||||
className="hidden group-hover:block h-5 w-5 transition-all opacity-50 hover:opacity-100"
|
className="hidden group-hover:block h-5 w-5 transition-all opacity-50 hover:opacity-100"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
clearSession(session.id).then(() => {
|
clearSession(session.id).then(() => {
|
||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { timestampMs } from "@zitadel/client";
|
import { timestampDate } from "@zitadel/client";
|
||||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -12,14 +12,6 @@ type Props = {
|
|||||||
authRequestId?: string;
|
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) {
|
export function SessionsList({ sessions, authRequestId }: Props) {
|
||||||
const t = useTranslations("accounts");
|
const t = useTranslations("accounts");
|
||||||
const [list, setList] = useState<Session[]>(sessions);
|
const [list, setList] = useState<Session[]>(sessions);
|
||||||
@@ -27,7 +19,17 @@ export function SessionsList({ sessions, authRequestId }: Props) {
|
|||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
{list
|
{list
|
||||||
.filter((session) => session?.factors?.user?.loginName)
|
.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) => {
|
.map((session, index) => {
|
||||||
return (
|
return (
|
||||||
<SessionItem
|
<SessionItem
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
symbolValidator,
|
symbolValidator,
|
||||||
upperCaseValidator,
|
upperCaseValidator,
|
||||||
} from "@/helpers/validators";
|
} from "@/helpers/validators";
|
||||||
import { changePassword, sendPassword } from "@/lib/server/password";
|
import {
|
||||||
|
changePassword,
|
||||||
|
resetPassword,
|
||||||
|
sendPassword,
|
||||||
|
} from "@/lib/server/password";
|
||||||
import { create } from "@zitadel/client";
|
import { create } from "@zitadel/client";
|
||||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||||
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||||
@@ -62,6 +66,29 @@ export function SetPasswordForm({
|
|||||||
|
|
||||||
const router = useRouter();
|
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) {
|
async function submitPassword(values: Inputs) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
let payload: { userId: string; password: string; code?: string } = {
|
let payload: { userId: string; password: string; code?: string } = {
|
||||||
@@ -184,6 +211,8 @@ export function SetPasswordForm({
|
|||||||
<Button
|
<Button
|
||||||
variant={ButtonVariants.Secondary}
|
variant={ButtonVariants.Secondary}
|
||||||
data-testid="resend-button"
|
data-testid="resend-button"
|
||||||
|
onClick={() => resendCode()}
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{t("set.resend")}
|
{t("set.resend")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ type resendVerifyEmailCommand = {
|
|||||||
|
|
||||||
export async function resendVerification(command: resendVerifyEmailCommand) {
|
export async function resendVerification(command: resendVerifyEmailCommand) {
|
||||||
return command.isInvite
|
return command.isInvite
|
||||||
? resendEmailCode(command.userId)
|
? resendInviteCode(command.userId)
|
||||||
: resendInviteCode(command.userId);
|
: resendEmailCode(command.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendVerificationRedirectWithoutCheck(command: {
|
export async function sendVerificationRedirectWithoutCheck(command: {
|
||||||
|
|||||||
@@ -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 { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||||
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getNextUrl } from "../client";
|
import { getNextUrl } from "../client";
|
||||||
import {
|
import {
|
||||||
getMostRecentSessionCookie,
|
getMostRecentSessionCookie,
|
||||||
@@ -108,7 +107,7 @@ export async function continueWithSession({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
if (url) {
|
if (url) {
|
||||||
return redirect(url);
|
return { redirect: url };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
export async function resendEmailCode(userId: string) {
|
||||||
return userService.resendEmailCode(
|
return userService.resendEmailCode(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user