diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index 2cea4b3b0b4..ad89312fcf1 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -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 => { + 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 { - // 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 { - 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 => { - 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 }, - ); } } diff --git a/apps/login/src/lib/oidc.ts b/apps/login/src/lib/oidc.ts new file mode 100644 index 00000000000..b7cca43d8c1 --- /dev/null +++ b/apps/login/src/lib/oidc.ts @@ -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 }); + } + } + } + } +} diff --git a/apps/login/src/lib/saml.ts b/apps/login/src/lib/saml.ts new file mode 100644 index 00000000000..d06dd59ef1f --- /dev/null +++ b/apps/login/src/lib/saml.ts @@ -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 }); + } + } + } + } +} diff --git a/apps/login/src/lib/service.ts b/apps/login/src/lib/service.ts index e543f35467f..03bf68645be 100644 --- a/apps/login/src/lib/service.ts +++ b/apps/login/src/lib/service.ts @@ -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( service: T, diff --git a/apps/login/src/lib/session.ts b/apps/login/src/lib/session.ts index 58fe1d20dfc..23f2801e7d0 100644 --- a/apps/login/src/lib/session.ts +++ b/apps/login/src/lib/session.ts @@ -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 { + // 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 { + 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; +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 535f4fd4cb3..ec628b81027 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -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, diff --git a/packages/zitadel-client/src/v2.ts b/packages/zitadel-client/src/v2.ts index 532a722f493..27dea14c4e6 100644 --- a/packages/zitadel-client/src/v2.ts +++ b/packages/zitadel-client/src/v2.ts @@ -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);