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 { idpTypeToSlug } from "@/lib/idp";
import { loginWithOIDCandSession } from "@/lib/oidc";
import { loginWithSAMLandSession } from "@/lib/saml";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
import { getServiceUrlFromHeaders } from "@/lib/service";
import { findValidSession } from "@/lib/session";
import {
createCallback,
getActiveIdentityProviders,
getAuthRequest,
getLoginSettings,
getOrgsByDomain,
listAuthenticationMethodTypes,
listSessions,
startIdentityProviderFlow,
} from "@/lib/zitadel";
import { create, timestampDate } from "@zitadel/client";
import { create } from "@zitadel/client";
import {
AuthRequest,
Prompt,
@@ -22,7 +23,6 @@ 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 { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
@@ -30,6 +30,29 @@ export const dynamic = "force-dynamic";
export const revalidate = false;
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({
serviceUrl,
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 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) {
const searchParams = request.nextUrl.searchParams;
const authRequestId = searchParams.get("authRequest");
const sessionId = searchParams.get("sessionId");
const _headers = await 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
const _rsc = searchParams.get("_rsc");
if (_rsc) {
@@ -221,402 +101,336 @@ export async function GET(request: NextRequest) {
sessions = await loadSessions({ serviceUrl, serviceRegion, ids });
}
if (authRequestId && sessionId) {
console.log(
`Login with session: ${sessionId} and authRequest: ${authRequestId}`,
);
const selectedSession = sessions.find((s) => s.id === sessionId);
if (selectedSession && selectedSession.id) {
console.log(`Found session ${selectedSession.id}`);
const isValid = await isSessionValid(
if (requestId && sessionId) {
if (requestId.startsWith("oidc_")) {
// this finishes the login process for OIDC
await loginWithOIDCandSession({
serviceUrl,
serviceRegion,
selectedSession,
);
authRequest: requestId.replace("oidc_", ""),
sessionId,
sessions,
sessionCookies,
request,
});
} else if (requestId.startsWith("saml_")) {
// this finishes the login process for SAML
await loginWithSAMLandSession({
serviceUrl,
serviceRegion,
samlRequest: requestId.replace("saml_", ""),
sessionId,
sessions,
sessionCookies,
request,
});
}
console.log("Session is valid:", isValid);
if (requestId) {
const { authRequest } = await getAuthRequest({
serviceUrl,
serviceRegion,
authRequestId: requestId,
});
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,
};
let organization = "";
let suffix = "";
let idpId = "";
const res = await sendLoginname(command);
if (authRequest?.scope) {
const orgScope = authRequest.scope.find((s: string) =>
ORG_SCOPE_REGEX.test(s),
);
if (res && "redirect" in res && res?.redirect) {
const absoluteUrl = new URL(res.redirect, request.url);
return NextResponse.redirect(absoluteUrl.toString());
const idpScope = authRequest.scope.find((s: string) =>
IDP_SCOPE_REGEX.test(s),
);
if (orgScope) {
const matched = ORG_SCOPE_REGEX.exec(orgScope);
organization = matched?.[1] ?? "";
} else {
const orgDomainScope = authRequest.scope.find((s: string) =>
ORG_DOMAIN_SCOPE_REGEX.test(s),
);
if (orgDomainScope) {
const matched = ORG_DOMAIN_SCOPE_REGEX.exec(orgDomainScope);
const orgDomain = matched?.[1] ?? "";
if (orgDomain) {
const orgs = await getOrgsByDomain({
serviceUrl,
serviceRegion,
domain: orgDomain,
});
if (orgs.result && orgs.result.length === 1) {
organization = orgs.result[0].id ?? "";
suffix = orgDomain;
}
}
}
}
if (idpScope) {
const matched = IDP_SCOPE_REGEX.exec(idpScope);
idpId = matched?.[1] ?? "";
const identityProviders = await getActiveIdentityProviders({
serviceUrl,
serviceRegion,
orgId: organization ? organization : undefined,
}).then((resp) => {
return resp.identityProviders;
});
const idp = identityProviders.find((idp) => idp.id === idpId);
if (idp) {
const origin = request.nextUrl.origin;
const identityProviderType = identityProviders[0].type;
let provider = idpTypeToSlug(identityProviderType);
const params = new URLSearchParams();
if (requestId) {
params.set("requestId", `oidc_${requestId}`);
}
if (organization) {
params.set("organization", organization);
}
return startIdentityProviderFlow({
serviceUrl,
serviceRegion,
idpId,
urls: {
successUrl:
`${origin}/idp/${provider}/success?` +
new URLSearchParams(params),
failureUrl:
`${origin}/idp/${provider}/failure?` +
new URLSearchParams(params),
},
}).then((resp) => {
if (
resp.nextStep.value &&
typeof resp.nextStep.value === "string"
) {
return NextResponse.redirect(resp.nextStep.value);
}
});
}
}
}
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id,
);
if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) {
const registerUrl = new URL("/register", request.url);
if (authRequest.id) {
registerUrl.searchParams.set("requestId", `oidc_${authRequest.id}`);
}
if (organization) {
registerUrl.searchParams.set("organization", organization);
}
if (cookie && cookie.id && cookie.token) {
const session = {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
return NextResponse.redirect(registerUrl);
}
// use existing session and hydrate it for oidc
if (authRequest && sessions.length) {
// if some accounts are available for selection and select_account is set
if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) {
return gotoAccounts({
request,
authRequest,
organization,
idPrefix: "oidc_",
});
} 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
*/
// 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" in res && 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(
"requestId",
`oidc_${authRequest.id}`,
);
}
if (authRequest.loginHint) {
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
}
if (organization) {
loginNameUrl.searchParams.set("organization", organization);
}
if (suffix) {
loginNameUrl.searchParams.set("suffix", suffix);
}
return NextResponse.redirect(loginNameUrl);
} else if (authRequest.prompt.includes(Prompt.NONE)) {
/**
* 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({
serviceUrl,
serviceRegion,
sessions,
authRequest,
});
if (!selectedSession || !selectedSession.id) {
return NextResponse.json(
{ error: "No active session found" },
{ status: 400 },
);
}
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 },
);
}
const session = {
sessionId: cookie.id,
sessionToken: cookie.token,
};
// works not with _rsc request
try {
const { callbackUrl } = await createCallback({
serviceUrl,
serviceRegion,
req: create(CreateCallbackRequestSchema, {
authRequestId,
authRequestId: requestId,
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 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,
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 });
}
}
}
}
}
if (authRequestId) {
const { authRequest } = await getAuthRequest({
serviceUrl,
serviceRegion,
authRequestId,
});
let organization = "";
let suffix = "";
let idpId = "";
if (authRequest?.scope) {
const orgScope = authRequest.scope.find((s: string) =>
ORG_SCOPE_REGEX.test(s),
);
const idpScope = authRequest.scope.find((s: string) =>
IDP_SCOPE_REGEX.test(s),
);
if (orgScope) {
const matched = ORG_SCOPE_REGEX.exec(orgScope);
organization = matched?.[1] ?? "";
} else {
const orgDomainScope = authRequest.scope.find((s: string) =>
ORG_DOMAIN_SCOPE_REGEX.test(s),
);
if (orgDomainScope) {
const matched = ORG_DOMAIN_SCOPE_REGEX.exec(orgDomainScope);
const orgDomain = matched?.[1] ?? "";
if (orgDomain) {
const orgs = await getOrgsByDomain({
serviceUrl,
serviceRegion,
domain: orgDomain,
});
if (orgs.result && orgs.result.length === 1) {
organization = orgs.result[0].id ?? "";
suffix = orgDomain;
}
}
}
}
if (idpScope) {
const matched = IDP_SCOPE_REGEX.exec(idpScope);
idpId = matched?.[1] ?? "";
const identityProviders = await getActiveIdentityProviders({
serviceUrl,
serviceRegion,
orgId: organization ? organization : undefined,
}).then((resp) => {
return resp.identityProviders;
});
const idp = identityProviders.find((idp) => idp.id === idpId);
if (idp) {
const origin = request.nextUrl.origin;
const identityProviderType = identityProviders[0].type;
let provider = idpTypeToSlug(identityProviderType);
const params = new URLSearchParams();
if (authRequestId) {
params.set("authRequestId", authRequestId);
}
if (organization) {
params.set("organization", organization);
}
return startIdentityProviderFlow({
return NextResponse.redirect(callbackUrl);
} else {
// check for loginHint, userId hint and valid sessions
let selectedSession = await findValidSession({
serviceUrl,
serviceRegion,
idpId,
urls: {
successUrl:
`${origin}/idp/${provider}/success?` +
new URLSearchParams(params),
failureUrl:
`${origin}/idp/${provider}/failure?` +
new URLSearchParams(params),
},
}).then((resp) => {
if (
resp.nextStep.value &&
typeof resp.nextStep.value === "string"
) {
return NextResponse.redirect(resp.nextStep.value);
}
sessions,
authRequest,
});
}
}
}
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);
}
if (!selectedSession || !selectedSession.id) {
return gotoAccounts({
request,
authRequest,
organization,
idPrefix: "oidc_",
});
}
return NextResponse.redirect(accountsUrl);
};
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession.id,
);
if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) {
const registerUrl = new URL("/register", request.url);
if (authRequest.id) {
registerUrl.searchParams.set("authRequestId", authRequest.id);
}
if (organization) {
registerUrl.searchParams.set("organization", organization);
}
if (!cookie || !cookie.id || !cookie.token) {
return gotoAccounts({
request,
authRequest,
organization,
idPrefix: "oidc_",
});
}
return NextResponse.redirect(registerUrl);
}
const session = {
sessionId: cookie.id,
sessionToken: cookie.token,
};
// use existing session and hydrate it for oidc
if (authRequest && sessions.length) {
// if some accounts are available for selection and select_account is set
if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) {
return gotoAccounts();
} 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
*/
// 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" in res && res?.redirect) {
const absoluteUrl = new URL(res.redirect, request.url);
return NextResponse.redirect(absoluteUrl.toString());
const { callbackUrl } = await createCallback({
serviceUrl,
serviceRegion,
req: create(CreateCallbackRequestSchema, {
authRequestId: requestId,
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({
request,
authRequest,
organization,
idPrefix: "oidc_",
});
}
} catch (error) {
console.error("Failed to execute sendLoginname:", error);
console.error(error);
return gotoAccounts({
request,
authRequest,
organization,
idPrefix: "oidc_",
});
}
}
} else {
const loginNameUrl = new URL("/loginname", request.url);
if (authRequest.id) {
loginNameUrl.searchParams.set("authRequestId", authRequest.id);
}
if (authRequest.loginHint) {
loginNameUrl.searchParams.set("requestId", `oidc_${requestId}`);
if (authRequest?.loginHint) {
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
loginNameUrl.searchParams.set("submit", "true"); // autosubmit
}
if (organization) {
loginNameUrl.searchParams.set("organization", organization);
}
if (suffix) {
loginNameUrl.searchParams.set("suffix", suffix);
}
return NextResponse.redirect(loginNameUrl);
} else if (authRequest.prompt.includes(Prompt.NONE)) {
/**
* 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(
serviceUrl,
serviceRegion,
sessions,
authRequest,
);
if (!selectedSession || !selectedSession.id) {
return NextResponse.json(
{ error: "No active session found" },
{ status: 400 },
);
}
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 },
);
}
const session = {
sessionId: cookie.id,
sessionToken: cookie.token,
};
const { callbackUrl } = await createCallback({
serviceUrl,
serviceRegion,
req: 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(
serviceUrl,
serviceRegion,
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({
serviceUrl,
serviceRegion,
req: 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();
}
}
} else {
const loginNameUrl = new URL("/loginname", request.url);
loginNameUrl.searchParams.set("authRequestId", authRequestId);
if (authRequest?.loginHint) {
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
loginNameUrl.searchParams.set("submit", "true"); // autosubmit
}
if (organization) {
loginNameUrl.searchParams.set("organization", organization);
}
return NextResponse.redirect(loginNameUrl);
return NextResponse.json(
{ error: "No authRequest nor samlRequest provided" },
{ status: 500 },
);
}
} else {
return NextResponse.json(
{ error: "No authRequestId provided" },
{ 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 { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_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 { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb";
import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
@@ -15,7 +16,8 @@ type ServiceClass =
| typeof OrganizationService
| typeof SessionService
| typeof OIDCService
| typeof SettingsService;
| typeof SettingsService
| typeof SAMLService;
export async function createServiceForHost<T extends ServiceClass>(
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 { 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 { getSession } from "./zitadel";
import {
getLoginSettings,
getSession,
listAuthenticationMethodTypes,
} from "./zitadel";
type LoadMostRecentSessionParams = {
serviceUrl: string;
@@ -29,3 +36,160 @@ export async function loadMostRecentSession({
sessionToken: recent.token,
}).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";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_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 {
Checks,
@@ -1030,6 +1034,24 @@ export async function createCallback({
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({
serviceUrl,
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 { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_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 { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_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 createSessionServiceClient = createClientFor(SessionService);
export const createOIDCServiceClient = createClientFor(OIDCService);
export const createSAMLServiceClient = createClientFor(SAMLService);
export const createOrganizationServiceClient = createClientFor(OrganizationService);
export const createFeatureServiceClient = createClientFor(FeatureService);
export const createIdpServiceClient = createClientFor(IdentityProviderService);