mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-12 09:05:12 +00:00
chore(login): Extract auth flow utilities and eliminate RSC request interference (#10644)
The /login route was experiencing issues with React Server Component
(RSC) requests interfering with one-time authentication callbacks. When
users navigated to /login via client-side routing (router.push()),
Next.js automatically triggered _rsc requests that could consume
single-use createCallback tokens, breaking OIDC and SAML authentication
flows.
# Which Problems Are Solved
When users attempt to log in, Next.js automatically makes requests with
the `_rsc=1` query parameter for React Server Components. The current
implementation treats these as server errors:
```typescript
// Before
if (_rsc) {
return NextResponse.json({ error: "No _rsc supported" }, { status: 500 });
}
```
This results in:
- Spurious 500 error logs polluting monitoring systems
- False alerts for server failures
- Difficulty distinguishing real issues from benign RSC requests
# How the Problems Are Solved
This PR implements a comprehensive refactoring that:
- Eliminates RSC interference by providing server actions for internal
auth flow completion
- Separates concerns between external flow initiation and internal flow
completion
- Extracts shared utilities to improve code maintainability and
reusability
- Maintains full backward compatibility for external applications
# Additional Context
## New Architecture
- auth-flow.ts: Shared utilities for auth flow completion with RSC
protection
- flow-initiation.ts: Extracted OIDC/SAML flow initiation logic (~400
lines)
- auth.ts: Server actions for internal components
## Route Handler Simplification
- route.ts: Reduced from ~350 lines to ~75 lines
- External-only focus: Now handles only flow initiation for external
applications
- Removed completion logic: External apps use their own callback URLs
- Enhanced validation: Early RSC blocking and parameter validation
## Flow Logic Improvements
- Early return patterns: Guard clauses eliminate deep nesting
- Better error handling: Specific error messages for different failure
modes
- Fixed SAML flow: Addressed incomplete logic
- Consistent session handling: Unified approach across OIDC and SAML
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { skipMFAAndContinueWithNextUrl } from "@/lib/server/session";
|
||||
import {
|
||||
LoginSettings,
|
||||
SecondFactorType,
|
||||
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { LoginSettings, SecondFactorType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { EMAIL, SMS, TOTP, U2F } from "./auth-methods";
|
||||
import { Translated } from "./translated";
|
||||
import { useState } from "react";
|
||||
import { Alert } from "./alert";
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
@@ -40,6 +39,8 @@ export function ChooseSecondFactorToSetup({
|
||||
const router = useRouter();
|
||||
const params = new URLSearchParams({});
|
||||
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
if (loginName) {
|
||||
params.append("loginName", loginName);
|
||||
}
|
||||
@@ -62,31 +63,15 @@ export function ChooseSecondFactorToSetup({
|
||||
{loginSettings.secondFactors.map((factor) => {
|
||||
switch (factor) {
|
||||
case SecondFactorType.OTP:
|
||||
return TOTP(
|
||||
userMethods.includes(AuthenticationMethodType.TOTP),
|
||||
"/otp/time-based/set?" + params,
|
||||
);
|
||||
return TOTP(userMethods.includes(AuthenticationMethodType.TOTP), "/otp/time-based/set?" + params);
|
||||
case SecondFactorType.U2F:
|
||||
return U2F(
|
||||
userMethods.includes(AuthenticationMethodType.U2F),
|
||||
"/u2f/set?" + params,
|
||||
);
|
||||
return U2F(userMethods.includes(AuthenticationMethodType.U2F), "/u2f/set?" + params);
|
||||
case SecondFactorType.OTP_EMAIL:
|
||||
return (
|
||||
emailVerified &&
|
||||
EMAIL(
|
||||
userMethods.includes(AuthenticationMethodType.OTP_EMAIL),
|
||||
"/otp/email/set?" + params,
|
||||
)
|
||||
emailVerified && EMAIL(userMethods.includes(AuthenticationMethodType.OTP_EMAIL), "/otp/email/set?" + params)
|
||||
);
|
||||
case SecondFactorType.OTP_SMS:
|
||||
return (
|
||||
phoneVerified &&
|
||||
SMS(
|
||||
userMethods.includes(AuthenticationMethodType.OTP_SMS),
|
||||
"/otp/sms/set?" + params,
|
||||
)
|
||||
);
|
||||
return phoneVerified && SMS(userMethods.includes(AuthenticationMethodType.OTP_SMS), "/otp/sms/set?" + params);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -96,7 +81,7 @@ export function ChooseSecondFactorToSetup({
|
||||
<button
|
||||
className="text-sm transition-all hover:text-primary-light-500 dark:hover:text-primary-dark-500"
|
||||
onClick={async () => {
|
||||
const resp = await skipMFAAndContinueWithNextUrl({
|
||||
const skipResponse = await skipMFAAndContinueWithNextUrl({
|
||||
userId,
|
||||
loginName,
|
||||
sessionId,
|
||||
@@ -104,8 +89,14 @@ export function ChooseSecondFactorToSetup({
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (resp?.redirect) {
|
||||
return router.push(resp.redirect);
|
||||
if (skipResponse && "error" in skipResponse && skipResponse.error) {
|
||||
setError(skipResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// For regular flows (non-OIDC/SAML), return URL for client-side navigation
|
||||
if ("redirect" in skipResponse && skipResponse.redirect) {
|
||||
router.push(skipResponse.redirect);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
@@ -114,6 +105,11 @@ export function ChooseSecondFactorToSetup({
|
||||
<Translated i18nKey="set.skip" namespace="mfa" />
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<div className="py-4" data-testid="error">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getNextUrl } from "@/lib/client";
|
||||
import { completeFlowOrGetUrl } from "@/lib/client";
|
||||
import { updateSession } from "@/lib/server/session";
|
||||
import { create } from "@zitadel/client";
|
||||
import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
||||
@@ -33,16 +33,7 @@ type Inputs = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
export function LoginOTP({
|
||||
host,
|
||||
loginName,
|
||||
sessionId,
|
||||
requestId,
|
||||
organization,
|
||||
method,
|
||||
code,
|
||||
loginSettings,
|
||||
}: Props) {
|
||||
export function LoginOTP({ host, loginName, sessionId, requestId, organization, method, code, loginSettings }: Props) {
|
||||
const t = useTranslations("otp");
|
||||
|
||||
const [error, setError] = useState<string>("");
|
||||
@@ -190,29 +181,33 @@ export function LoginOTP({
|
||||
// Wait for 2 seconds to avoid eventual consistency issues with an OTP code being verified in the /login endpoint
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const url =
|
||||
requestId && response.sessionId
|
||||
? await getNextUrl(
|
||||
{
|
||||
// Use unified approach that handles both OIDC/SAML and regular flows
|
||||
if (response.factors?.user) {
|
||||
const callbackResponse = await completeFlowOrGetUrl(
|
||||
requestId && response.sessionId
|
||||
? {
|
||||
sessionId: response.sessionId,
|
||||
requestId: requestId,
|
||||
organization: response.factors?.user?.organizationId,
|
||||
}
|
||||
: {
|
||||
loginName: response.factors.user.loginName,
|
||||
organization: response.factors?.user?.organizationId,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: response.factors?.user
|
||||
? await getNextUrl(
|
||||
{
|
||||
loginName: response.factors.user.loginName,
|
||||
organization: response.factors?.user?.organizationId,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: null;
|
||||
loginSettings?.defaultRedirectUri,
|
||||
);
|
||||
setLoading(false);
|
||||
|
||||
setLoading(false);
|
||||
if (url) {
|
||||
router.push(url);
|
||||
if ("error" in callbackResponse) {
|
||||
setError(callbackResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("redirect" in callbackResponse) {
|
||||
return router.push(callbackResponse.redirect);
|
||||
}
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -278,8 +273,7 @@ export function LoginOTP({
|
||||
})}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="verify.submit" namespace="otp" />
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />} <Translated i18nKey="verify.submit" namespace="otp" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -26,16 +26,11 @@ type Props = {
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export function PasswordForm({
|
||||
loginSettings,
|
||||
loginName,
|
||||
organization,
|
||||
requestId,
|
||||
}: Props) {
|
||||
export function PasswordForm({ loginSettings, loginName, organization, requestId }: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
|
||||
const t = useTranslations("password");
|
||||
|
||||
const [info, setInfo] = useState<string>("");
|
||||
@@ -57,8 +52,9 @@ export function PasswordForm({
|
||||
}),
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
setError("Could not verify password");
|
||||
console.error("Error verifying password:", error);
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -137,14 +133,7 @@ export function PasswordForm({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{loginName && (
|
||||
<input
|
||||
type="hidden"
|
||||
name="loginName"
|
||||
autoComplete="username"
|
||||
value={loginName}
|
||||
/>
|
||||
)}
|
||||
{loginName && <input type="hidden" name="loginName" autoComplete="username" value={loginName} />}
|
||||
</div>
|
||||
|
||||
{info && (
|
||||
@@ -170,8 +159,7 @@ export function PasswordForm({
|
||||
onClick={handleSubmit(submitPassword)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="verify.submit" namespace="password" />
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />} <Translated i18nKey="verify.submit" namespace="password" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { registerUserAndLinkToIDP } from "@/lib/server/register";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FieldValues, useForm } from "react-hook-form";
|
||||
@@ -60,8 +59,6 @@ export function RegisterFormIDPIncomplete({
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitAndRegister(values: Inputs) {
|
||||
setLoading(true);
|
||||
const response = await registerUserAndLinkToIDP({
|
||||
@@ -88,11 +85,7 @@ export function RegisterFormIDPIncomplete({
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && "redirect" in response && response.redirect) {
|
||||
return router.push(response.redirect);
|
||||
}
|
||||
|
||||
return response;
|
||||
// If no error, the function has already handled the redirect
|
||||
}
|
||||
|
||||
const { errors } = formState;
|
||||
@@ -150,8 +143,7 @@ export function RegisterFormIDPIncomplete({
|
||||
onClick={handleSubmit(submitAndRegister)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="submit" namespace="register" />
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />} <Translated i18nKey="submit" namespace="register" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
|
||||
import { getNextUrl } from "@/lib/client";
|
||||
import { completeFlowOrGetUrl } from "@/lib/client";
|
||||
import { addU2F, verifyU2F } from "@/lib/server/u2f";
|
||||
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { RegisterU2FResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
@@ -22,26 +22,14 @@ type Props = {
|
||||
loginSettings?: LoginSettings;
|
||||
};
|
||||
|
||||
export function RegisterU2f({
|
||||
loginName,
|
||||
sessionId,
|
||||
organization,
|
||||
requestId,
|
||||
checkAfter,
|
||||
loginSettings,
|
||||
}: Props) {
|
||||
export function RegisterU2f({ loginName, sessionId, organization, requestId, checkAfter, loginSettings }: Props) {
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitVerify(
|
||||
u2fId: string,
|
||||
passkeyName: string,
|
||||
publicKeyCredential: any,
|
||||
sessionId: string,
|
||||
) {
|
||||
async function submitVerify(u2fId: string, passkeyName: string, publicKeyCredential: any, sessionId: string) {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const response = await verifyU2F({
|
||||
@@ -94,24 +82,14 @@ export function RegisterU2f({
|
||||
|
||||
const u2fId = u2fResponse.u2fId;
|
||||
const options: CredentialCreationOptions =
|
||||
(u2fResponse?.publicKeyCredentialCreationOptions as CredentialCreationOptions) ??
|
||||
{};
|
||||
(u2fResponse?.publicKeyCredentialCreationOptions as CredentialCreationOptions) ?? {};
|
||||
|
||||
if (options.publicKey) {
|
||||
options.publicKey.challenge = coerceToArrayBuffer(
|
||||
options.publicKey.challenge,
|
||||
"challenge",
|
||||
);
|
||||
options.publicKey.user.id = coerceToArrayBuffer(
|
||||
options.publicKey.user.id,
|
||||
"userid",
|
||||
);
|
||||
options.publicKey.challenge = coerceToArrayBuffer(options.publicKey.challenge, "challenge");
|
||||
options.publicKey.user.id = coerceToArrayBuffer(options.publicKey.user.id, "userid");
|
||||
if (options.publicKey.excludeCredentials) {
|
||||
options.publicKey.excludeCredentials.map((cred: any) => {
|
||||
cred.id = coerceToArrayBuffer(
|
||||
cred.id as string,
|
||||
"excludeCredentials.id",
|
||||
);
|
||||
cred.id = coerceToArrayBuffer(cred.id as string, "excludeCredentials.id");
|
||||
return cred;
|
||||
});
|
||||
}
|
||||
@@ -137,10 +115,7 @@ export function RegisterU2f({
|
||||
rawId: coerceToBase64Url(rawId, "rawId"),
|
||||
type: resp.type,
|
||||
response: {
|
||||
attestationObject: coerceToBase64Url(
|
||||
attestationObject,
|
||||
"attestationObject",
|
||||
),
|
||||
attestationObject: coerceToBase64Url(attestationObject, "attestationObject"),
|
||||
clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"),
|
||||
},
|
||||
};
|
||||
@@ -170,27 +145,41 @@ export function RegisterU2f({
|
||||
|
||||
return router.push(`/u2f?` + paramsToContinue);
|
||||
} else {
|
||||
const url =
|
||||
requestId && sessionId
|
||||
? await getNextUrl(
|
||||
{
|
||||
sessionId: sessionId,
|
||||
requestId: requestId,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: loginName
|
||||
? await getNextUrl(
|
||||
{
|
||||
loginName: loginName,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: null;
|
||||
if (url) {
|
||||
return router.push(url);
|
||||
if (requestId && sessionId) {
|
||||
const callbackResponse = await completeFlowOrGetUrl(
|
||||
{
|
||||
sessionId: sessionId,
|
||||
requestId: requestId,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
);
|
||||
|
||||
if ("error" in callbackResponse) {
|
||||
setError(callbackResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("redirect" in callbackResponse) {
|
||||
return router.push(callbackResponse.redirect);
|
||||
}
|
||||
} else if (loginName) {
|
||||
const callbackResponse = await completeFlowOrGetUrl(
|
||||
{
|
||||
loginName: loginName,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
);
|
||||
|
||||
if ("error" in callbackResponse) {
|
||||
setError(callbackResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("redirect" in callbackResponse) {
|
||||
return router.push(callbackResponse.redirect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,8 +205,7 @@ export function RegisterU2f({
|
||||
onClick={submitRegisterAndContinue}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="set.submit" namespace="u2f" />
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />} <Translated i18nKey="set.submit" namespace="u2f" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -73,14 +73,10 @@ export function SessionItem({
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (valid && session?.factors?.user) {
|
||||
const resp = await continueWithSession({
|
||||
await continueWithSession({
|
||||
...session,
|
||||
requestId: requestId,
|
||||
});
|
||||
|
||||
if (resp?.redirect) {
|
||||
return router.push(resp.redirect);
|
||||
}
|
||||
} else if (session.factors?.user) {
|
||||
setLoading(true);
|
||||
const res = await sendLoginname({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getNextUrl } from "@/lib/client";
|
||||
import { completeFlowOrGetUrl } from "@/lib/client";
|
||||
import { verifyTOTP } from "@/lib/server/verify";
|
||||
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import Link from "next/link";
|
||||
@@ -30,15 +30,7 @@ type Props = {
|
||||
checkAfter?: boolean;
|
||||
loginSettings?: LoginSettings;
|
||||
};
|
||||
export function TotpRegister({
|
||||
uri,
|
||||
loginName,
|
||||
sessionId,
|
||||
requestId,
|
||||
organization,
|
||||
checkAfter,
|
||||
loginSettings,
|
||||
}: Props) {
|
||||
export function TotpRegister({ uri, loginName, sessionId, requestId, organization, checkAfter, loginSettings }: Props) {
|
||||
const [error, setError] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
@@ -72,28 +64,41 @@ export function TotpRegister({
|
||||
|
||||
return router.push(`/otp/time-based?` + params);
|
||||
} else {
|
||||
const url =
|
||||
requestId && sessionId
|
||||
? await getNextUrl(
|
||||
{
|
||||
sessionId: sessionId,
|
||||
requestId: requestId,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: loginName
|
||||
? await getNextUrl(
|
||||
{
|
||||
loginName: loginName,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: null;
|
||||
if (requestId && sessionId) {
|
||||
const callbackResponse = await completeFlowOrGetUrl(
|
||||
{
|
||||
sessionId: sessionId,
|
||||
requestId: requestId,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
);
|
||||
|
||||
if (url) {
|
||||
return router.push(url);
|
||||
if ("error" in callbackResponse) {
|
||||
setError(callbackResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("redirect" in callbackResponse) {
|
||||
return router.push(callbackResponse.redirect);
|
||||
}
|
||||
} else if (loginName) {
|
||||
const callbackResponse = await completeFlowOrGetUrl(
|
||||
{
|
||||
loginName: loginName,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
);
|
||||
|
||||
if ("error" in callbackResponse) {
|
||||
setError(callbackResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("redirect" in callbackResponse) {
|
||||
return router.push(callbackResponse.redirect);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -110,10 +115,7 @@ export function TotpRegister({
|
||||
<div className="flex flex-col items-center">
|
||||
{uri && (
|
||||
<>
|
||||
<QRCodeSVG
|
||||
className="my-4 h-40 w-40 rounded-md bg-white p-2"
|
||||
value={uri}
|
||||
/>
|
||||
<QRCodeSVG className="my-4 h-40 w-40 rounded-md bg-white p-2" value={uri} />
|
||||
<div className="my-2 mb-4 flex w-96 rounded-lg border border-divider-light px-4 py-2 pr-2 text-sm dark:border-divider-dark">
|
||||
<Link href={uri} target="_blank" className="flex-1 overflow-x-auto">
|
||||
{uri}
|
||||
|
||||
Reference in New Issue
Block a user