This commit is contained in:
Max Peintner
2025-02-04 09:38:33 +01:00
parent 0bc2c1d876
commit 6fad38ec93
7 changed files with 785 additions and 511 deletions

View File

@@ -1,18 +1,19 @@
import { getAllSessions } from "@/lib/cookies"; import { getAllSessions } from "@/lib/cookies";
import { idpTypeToSlug } from "@/lib/idp"; import { idpTypeToSlug } from "@/lib/idp";
import { loginWithOIDCandSession } from "@/lib/oidc";
import { loginWithSAMLandSession } from "@/lib/saml";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
import { getServiceUrlFromHeaders } from "@/lib/service"; import { getServiceUrlFromHeaders } from "@/lib/service";
import { findValidSession } from "@/lib/session";
import { import {
createCallback, createCallback,
getActiveIdentityProviders, getActiveIdentityProviders,
getAuthRequest, getAuthRequest,
getLoginSettings,
getOrgsByDomain, getOrgsByDomain,
listAuthenticationMethodTypes,
listSessions, listSessions,
startIdentityProviderFlow, startIdentityProviderFlow,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { create, timestampDate } from "@zitadel/client"; import { create } from "@zitadel/client";
import { import {
AuthRequest, AuthRequest,
Prompt, Prompt,
@@ -22,7 +23,6 @@ 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 { headers } from "next/headers"; import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
@@ -30,6 +30,29 @@ export const dynamic = "force-dynamic";
export const revalidate = false; export const revalidate = false;
export const fetchCache = "default-no-store"; export const fetchCache = "default-no-store";
const gotoAccounts = ({
request,
authRequest,
organization,
idPrefix,
}: {
request: NextRequest;
authRequest: AuthRequest;
organization: string;
idPrefix: string;
}): NextResponse<unknown> => {
const accountsUrl = new URL("/accounts", request.url);
if (authRequest?.id) {
accountsUrl.searchParams.set("requestId", `${idPrefix}${authRequest.id}`);
}
if (organization) {
accountsUrl.searchParams.set("organization", organization);
}
return NextResponse.redirect(accountsUrl);
};
async function loadSessions({ async function loadSessions({
serviceUrl, serviceUrl,
serviceRegion, serviceRegion,
@@ -52,162 +75,19 @@ 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:(.+)/;
/**
* 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(
serviceUrl: string,
serviceRegion: string,
session: Session,
): Promise<boolean> {
// session can't be checked without user
if (!session.factors?.user) {
console.warn("Session has no user");
return false;
}
let mfaValid = true;
const authMethodTypes = await listAuthenticationMethodTypes({
serviceUrl,
serviceRegion,
userId: 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({
serviceUrl,
serviceRegion,
organization: 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;
const idp = session.factors.intent?.verifiedAt; // TODO: forceMFA should not consider this as valid factor
// must have one single check
mfaValid = !!(otpEmail || otpSms || totp || webAuthN || idp);
if (!mfaValid) {
console.warn("Session has no valid multifactor", 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",
);
}
const validChecks = !!(validPassword || validPasskey || validIDP);
return stillValid && validChecks && mfaValid;
}
async function findValidSession(
serviceUrl: string,
serviceRegion: string,
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(serviceUrl, serviceRegion, session)) {
return session;
}
}
return undefined;
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const authRequestId = searchParams.get("authRequest");
const sessionId = searchParams.get("sessionId");
const _headers = await headers(); const _headers = await headers();
const { serviceUrl, serviceRegion } = getServiceUrlFromHeaders(_headers); const { serviceUrl, serviceRegion } = getServiceUrlFromHeaders(_headers);
const searchParams = request.nextUrl.searchParams;
const oidcRequestId = searchParams.get("authRequest"); // oidc initiated request
const samlRequestId = searchParams.get("samlRequest"); // saml initiated request
const requestId = searchParams.get("requestId"); // internal request id which combines authRequest and samlRequest with the prefix oidc_ or saml_
const sessionId = searchParams.get("sessionId");
// TODO: find a better way to handle _rsc (react server components) requests and block them to avoid conflicts when creating oidc callback // TODO: find a better way to handle _rsc (react server components) requests and block them to avoid conflicts when creating oidc callback
const _rsc = searchParams.get("_rsc"); const _rsc = searchParams.get("_rsc");
if (_rsc) { if (_rsc) {
@@ -221,119 +101,36 @@ export async function GET(request: NextRequest) {
sessions = await loadSessions({ serviceUrl, serviceRegion, ids }); sessions = await loadSessions({ serviceUrl, serviceRegion, ids });
} }
if (authRequestId && sessionId) { if (requestId && sessionId) {
console.log( if (requestId.startsWith("oidc_")) {
`Login with session: ${sessionId} and authRequest: ${authRequestId}`, // this finishes the login process for OIDC
); await loginWithOIDCandSession({
const selectedSession = sessions.find((s) => s.id === sessionId);
if (selectedSession && selectedSession.id) {
console.log(`Found session ${selectedSession.id}`);
const isValid = await isSessionValid(
serviceUrl, serviceUrl,
serviceRegion, serviceRegion,
selectedSession, authRequest: requestId.replace("oidc_", ""),
); sessionId,
sessions,
console.log("Session is valid:", isValid); sessionCookies,
request,
if (!isValid && selectedSession.factors?.user) {
// if the session is not valid anymore, we need to redirect the user to re-authenticate /
// TODO: handle IDP intent direcly if available
const command: SendLoginnameCommand = {
loginName: selectedSession.factors.user?.loginName,
organization: selectedSession.factors?.user?.organizationId,
authRequestId: authRequestId,
};
const res = await sendLoginname(command);
if (res && "redirect" in res && res?.redirect) {
const absoluteUrl = new URL(res.redirect, request.url);
return NextResponse.redirect(absoluteUrl.toString());
}
}
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id,
);
if (cookie && cookie.id && cookie.token) {
const session = {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
// works not with _rsc request
try {
const { callbackUrl } = await createCallback({
serviceUrl,
serviceRegion,
req: create(CreateCallbackRequestSchema, {
authRequestId,
callbackKind: {
case: "session",
value: create(SessionSchema, session),
},
}),
}); });
if (callbackUrl) { } else if (requestId.startsWith("saml_")) {
return NextResponse.redirect(callbackUrl); // this finishes the login process for SAML
} else { await loginWithSAMLandSession({
return NextResponse.json(
{ error: "An error occurred!" },
{ 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({
serviceUrl, serviceUrl,
serviceRegion, serviceRegion,
organization: selectedSession.factors?.user?.organizationId, samlRequest: requestId.replace("saml_", ""),
sessionId,
sessions,
sessionCookies,
request,
}); });
if (loginSettings?.defaultRedirectUri) {
return NextResponse.redirect(loginSettings.defaultRedirectUri);
} }
const signedinUrl = new URL("/signedin", request.url); if (requestId) {
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 });
}
}
}
}
}
if (authRequestId) {
const { authRequest } = await getAuthRequest({ const { authRequest } = await getAuthRequest({
serviceUrl, serviceUrl,
serviceRegion, serviceRegion,
authRequestId, authRequestId: requestId,
}); });
let organization = ""; let organization = "";
@@ -396,8 +193,8 @@ export async function GET(request: NextRequest) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (authRequestId) { if (requestId) {
params.set("authRequestId", authRequestId); params.set("requestId", `oidc_${requestId}`);
} }
if (organization) { if (organization) {
@@ -428,22 +225,10 @@ export async function GET(request: NextRequest) {
} }
} }
const gotoAccounts = (): NextResponse<unknown> => {
const accountsUrl = new URL("/accounts", request.url);
if (authRequest?.id) {
accountsUrl.searchParams.set("authRequestId", authRequest?.id);
}
if (organization) {
accountsUrl.searchParams.set("organization", organization);
}
return NextResponse.redirect(accountsUrl);
};
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("requestId", `oidc_${authRequest.id}`);
} }
if (organization) { if (organization) {
registerUrl.searchParams.set("organization", organization); registerUrl.searchParams.set("organization", organization);
@@ -456,7 +241,12 @@ export async function GET(request: NextRequest) {
if (authRequest && sessions.length) { if (authRequest && sessions.length) {
// if some accounts are available for selection and select_account is set // if some accounts are available for selection and select_account is set
if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) { if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) {
return gotoAccounts(); return gotoAccounts({
request,
authRequest,
organization,
idPrefix: "oidc_",
});
} else if (authRequest.prompt.includes(Prompt.LOGIN)) { } else if (authRequest.prompt.includes(Prompt.LOGIN)) {
/** /**
* The login prompt instructs the authentication server to prompt the user for re-authentication, regardless of whether the user is already authenticated * The login prompt instructs the authentication server to prompt the user for re-authentication, regardless of whether the user is already authenticated
@@ -487,7 +277,10 @@ export async function GET(request: NextRequest) {
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(
"requestId",
`oidc_${authRequest.id}`,
);
} }
if (authRequest.loginHint) { if (authRequest.loginHint) {
loginNameUrl.searchParams.set("loginName", authRequest.loginHint); loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
@@ -505,12 +298,12 @@ export async function GET(request: NextRequest) {
* This means that the user should not be prompted to enter their password again. * 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 * 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( const selectedSession = await findValidSession({
serviceUrl, serviceUrl,
serviceRegion, serviceRegion,
sessions, sessions,
authRequest, authRequest,
); });
if (!selectedSession || !selectedSession.id) { if (!selectedSession || !selectedSession.id) {
return NextResponse.json( return NextResponse.json(
@@ -539,7 +332,7 @@ export async function GET(request: NextRequest) {
serviceUrl, serviceUrl,
serviceRegion, serviceRegion,
req: create(CreateCallbackRequestSchema, { req: create(CreateCallbackRequestSchema, {
authRequestId, authRequestId: requestId,
callbackKind: { callbackKind: {
case: "session", case: "session",
value: create(SessionSchema, session), value: create(SessionSchema, session),
@@ -549,15 +342,20 @@ export async function GET(request: NextRequest) {
return NextResponse.redirect(callbackUrl); return NextResponse.redirect(callbackUrl);
} else { } else {
// check for loginHint, userId hint and valid sessions // check for loginHint, userId hint and valid sessions
let selectedSession = await findValidSession( let selectedSession = await findValidSession({
serviceUrl, serviceUrl,
serviceRegion, serviceRegion,
sessions, sessions,
authRequest, authRequest,
); });
if (!selectedSession || !selectedSession.id) { if (!selectedSession || !selectedSession.id) {
return gotoAccounts(); return gotoAccounts({
request,
authRequest,
organization,
idPrefix: "oidc_",
});
} }
const cookie = sessionCookies.find( const cookie = sessionCookies.find(
@@ -565,7 +363,12 @@ export async function GET(request: NextRequest) {
); );
if (!cookie || !cookie.id || !cookie.token) { if (!cookie || !cookie.id || !cookie.token) {
return gotoAccounts(); return gotoAccounts({
request,
authRequest,
organization,
idPrefix: "oidc_",
});
} }
const session = { const session = {
@@ -578,7 +381,7 @@ export async function GET(request: NextRequest) {
serviceUrl, serviceUrl,
serviceRegion, serviceRegion,
req: create(CreateCallbackRequestSchema, { req: create(CreateCallbackRequestSchema, {
authRequestId, authRequestId: requestId,
callbackKind: { callbackKind: {
case: "session", case: "session",
value: create(SessionSchema, session), value: create(SessionSchema, session),
@@ -591,17 +394,27 @@ export async function GET(request: NextRequest) {
console.log( console.log(
"could not create callback, redirect user to choose other account", "could not create callback, redirect user to choose other account",
); );
return gotoAccounts(); return gotoAccounts({
request,
authRequest,
organization,
idPrefix: "oidc_",
});
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return gotoAccounts(); return gotoAccounts({
request,
authRequest,
organization,
idPrefix: "oidc_",
});
} }
} }
} else { } else {
const loginNameUrl = new URL("/loginname", request.url); const loginNameUrl = new URL("/loginname", request.url);
loginNameUrl.searchParams.set("authRequestId", authRequestId); loginNameUrl.searchParams.set("requestId", `oidc_${requestId}`);
if (authRequest?.loginHint) { if (authRequest?.loginHint) {
loginNameUrl.searchParams.set("loginName", authRequest.loginHint); loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
loginNameUrl.searchParams.set("submit", "true"); // autosubmit loginNameUrl.searchParams.set("submit", "true"); // autosubmit
@@ -615,8 +428,9 @@ export async function GET(request: NextRequest) {
} }
} else { } else {
return NextResponse.json( return NextResponse.json(
{ error: "No authRequestId provided" }, { error: "No authRequest nor samlRequest provided" },
{ status: 500 }, { status: 500 },
); );
} }
}
} }

136
apps/login/src/lib/oidc.ts Normal file
View File

@@ -0,0 +1,136 @@
import { Cookie } from "@/lib/cookies";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
import { createCallback, getLoginSettings } from "@/lib/zitadel";
import { create } from "@zitadel/client";
import {
CreateCallbackRequestSchema,
SessionSchema,
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { NextRequest, NextResponse } from "next/server";
import { isSessionValid } from "./session";
type LoginWithOIDCandSession = {
serviceUrl: string;
serviceRegion: string;
authRequest: string;
sessionId: string;
sessions: Session[];
sessionCookies: Cookie[];
request: NextRequest;
};
export async function loginWithOIDCandSession({
serviceUrl,
serviceRegion,
authRequest,
sessionId,
sessions,
sessionCookies,
request,
}: LoginWithOIDCandSession) {
console.log(
`Login with session: ${sessionId} and authRequest: ${authRequest}`,
);
const selectedSession = sessions.find((s) => s.id === sessionId);
if (selectedSession && selectedSession.id) {
console.log(`Found session ${selectedSession.id}`);
const isValid = await isSessionValid({
serviceUrl,
serviceRegion,
session: selectedSession,
});
console.log("Session is valid:", isValid);
if (!isValid && selectedSession.factors?.user) {
// if the session is not valid anymore, we need to redirect the user to re-authenticate /
// TODO: handle IDP intent direcly if available
const command: SendLoginnameCommand = {
loginName: selectedSession.factors.user?.loginName,
organization: selectedSession.factors?.user?.organizationId,
authRequestId: authRequest,
};
const res = await sendLoginname(command);
if (res && "redirect" in res && res?.redirect) {
const absoluteUrl = new URL(res.redirect, request.url);
return NextResponse.redirect(absoluteUrl.toString());
}
}
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id,
);
if (cookie && cookie.id && cookie.token) {
const session = {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
// works not with _rsc request
try {
const { callbackUrl } = await createCallback({
serviceUrl,
serviceRegion,
req: create(CreateCallbackRequestSchema, {
authRequestId: authRequest,
callbackKind: {
case: "session",
value: create(SessionSchema, session),
},
}),
});
if (callbackUrl) {
return NextResponse.redirect(callbackUrl);
} else {
return NextResponse.json(
{ error: "An error occurred!" },
{ status: 500 },
);
}
} catch (error: unknown) {
// handle already handled gracefully as these could come up if old emails with requestId 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({
serviceUrl,
serviceRegion,
organization: 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 });
}
}
}
}
}

134
apps/login/src/lib/saml.ts Normal file
View File

@@ -0,0 +1,134 @@
import { Cookie } from "@/lib/cookies";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
import { createResponse, getLoginSettings } from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { NextRequest, NextResponse } from "next/server";
import { isSessionValid } from "./session";
type LoginWithSAMLandSession = {
serviceUrl: string;
serviceRegion: string;
samlRequest: string;
sessionId: string;
sessions: Session[];
sessionCookies: Cookie[];
request: NextRequest;
};
export async function loginWithSAMLandSession({
serviceUrl,
serviceRegion,
samlRequest,
sessionId,
sessions,
sessionCookies,
request,
}: LoginWithSAMLandSession) {
console.log(
`Login with session: ${sessionId} and samlRequest: ${samlRequest}`,
);
const selectedSession = sessions.find((s) => s.id === sessionId);
if (selectedSession && selectedSession.id) {
console.log(`Found session ${selectedSession.id}`);
const isValid = await isSessionValid({
serviceUrl,
serviceRegion,
session: selectedSession,
});
console.log("Session is valid:", isValid);
if (!isValid && selectedSession.factors?.user) {
// if the session is not valid anymore, we need to redirect the user to re-authenticate /
// TODO: handle IDP intent direcly if available
const command: SendLoginnameCommand = {
loginName: selectedSession.factors.user?.loginName,
organization: selectedSession.factors?.user?.organizationId,
authRequestId: samlRequest,
};
const res = await sendLoginname(command);
if (res && "redirect" in res && res?.redirect) {
const absoluteUrl = new URL(res.redirect, request.url);
return NextResponse.redirect(absoluteUrl.toString());
}
}
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id,
);
if (cookie && cookie.id && cookie.token) {
const session = {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
// works not with _rsc request
try {
const { url } = await createResponse({
serviceUrl,
serviceRegion,
req: create(CreateResponseRequestSchema, {
samlRequestId: samlRequest,
responseKind: {
case: "session",
value: session,
},
}),
});
if (url) {
return NextResponse.redirect(url);
} else {
return NextResponse.json(
{ error: "An error occurred!" },
{ status: 500 },
);
}
} catch (error: unknown) {
// handle already handled gracefully as these could come up if old emails with requestId 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({
serviceUrl,
serviceRegion,
organization: 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 });
}
}
}
}
}

View File

@@ -3,6 +3,7 @@ import { createServerTransport } from "@zitadel/client/node";
import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb"; import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb";
import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb"; import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb";
import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb";
import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
@@ -15,7 +16,8 @@ type ServiceClass =
| typeof OrganizationService | typeof OrganizationService
| typeof SessionService | typeof SessionService
| typeof OIDCService | typeof OIDCService
| typeof SettingsService; | typeof SettingsService
| typeof SAMLService;
export async function createServiceForHost<T extends ServiceClass>( export async function createServiceForHost<T extends ServiceClass>(
service: T, service: T,

View File

@@ -1,7 +1,14 @@
import { timestampDate } from "@zitadel/client";
import { AuthRequest } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { GetSessionResponse } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { GetSessionResponse } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getMostRecentCookieWithLoginname } from "./cookies"; import { getMostRecentCookieWithLoginname } from "./cookies";
import { getSession } from "./zitadel"; import {
getLoginSettings,
getSession,
listAuthenticationMethodTypes,
} from "./zitadel";
type LoadMostRecentSessionParams = { type LoadMostRecentSessionParams = {
serviceUrl: string; serviceUrl: string;
@@ -29,3 +36,160 @@ export async function loadMostRecentSession({
sessionToken: recent.token, sessionToken: recent.token,
}).then((resp: GetSessionResponse) => resp.session); }).then((resp: GetSessionResponse) => resp.session);
} }
/**
* 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);
**/
export async function isSessionValid({
serviceUrl,
serviceRegion,
session,
}: {
serviceUrl: string;
serviceRegion: string;
session: Session;
}): Promise<boolean> {
// session can't be checked without user
if (!session.factors?.user) {
console.warn("Session has no user");
return false;
}
let mfaValid = true;
const authMethodTypes = await listAuthenticationMethodTypes({
serviceUrl,
serviceRegion,
userId: 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({
serviceUrl,
serviceRegion,
organization: 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;
const idp = session.factors.intent?.verifiedAt; // TODO: forceMFA should not consider this as valid factor
// must have one single check
mfaValid = !!(otpEmail || otpSms || totp || webAuthN || idp);
if (!mfaValid) {
console.warn("Session has no valid multifactor", 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",
);
}
const validChecks = !!(validPassword || validPasskey || validIDP);
return stillValid && validChecks && mfaValid;
}
export async function findValidSession({
serviceUrl,
serviceRegion,
sessions,
authRequest,
}: {
serviceUrl: string;
serviceRegion: string;
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({ serviceUrl, serviceRegion, session })) {
return session;
}
}
return undefined;
}

View File

@@ -8,6 +8,10 @@ import {
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb"; import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb";
import {
CreateResponseRequest,
SAMLService,
} from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
import { import {
Checks, Checks,
@@ -1030,6 +1034,24 @@ export async function createCallback({
return oidcService.createCallback(req); return oidcService.createCallback(req);
} }
export async function createResponse({
serviceUrl,
serviceRegion,
req,
}: {
serviceUrl: string;
serviceRegion: string;
req: CreateResponseRequest;
}) {
const samlService = await createServiceForHost(
SAMLService,
serviceUrl,
serviceRegion,
);
return samlService.createResponse(req);
}
export async function verifyEmail({ export async function verifyEmail({
serviceUrl, serviceUrl,
serviceRegion, serviceRegion,

View File

@@ -4,6 +4,7 @@ import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_servi
import { RequestContextSchema } from "@zitadel/proto/zitadel/object/v2/object_pb"; import { RequestContextSchema } from "@zitadel/proto/zitadel/object/v2/object_pb";
import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb"; import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb";
import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb";
import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
@@ -14,6 +15,7 @@ export const createUserServiceClient = createClientFor(UserService);
export const createSettingsServiceClient = createClientFor(SettingsService); export const createSettingsServiceClient = createClientFor(SettingsService);
export const createSessionServiceClient = createClientFor(SessionService); export const createSessionServiceClient = createClientFor(SessionService);
export const createOIDCServiceClient = createClientFor(OIDCService); export const createOIDCServiceClient = createClientFor(OIDCService);
export const createSAMLServiceClient = createClientFor(SAMLService);
export const createOrganizationServiceClient = createClientFor(OrganizationService); export const createOrganizationServiceClient = createClientFor(OrganizationService);
export const createFeatureServiceClient = createClientFor(FeatureService); export const createFeatureServiceClient = createClientFor(FeatureService);
export const createIdpServiceClient = createClientFor(IdentityProviderService); export const createIdpServiceClient = createClientFor(IdentityProviderService);