mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
fix: handle invitations in social and SAML auth (#11752)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
441f2a3c48
commit
7b5d724bb7
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.32.1] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Invitation callback paths are now preserved when invited users continue with Google, GitHub, or SAML authentication [(#11752)](https://github.com/prowler-cloud/prowler/pull/11752)
|
||||
|
||||
---
|
||||
|
||||
## [1.32.0] (Prowler v5.32.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -166,8 +166,13 @@ export const deleteSamlConfig = async (id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const initiateSamlAuth = async (email: string) => {
|
||||
export const initiateSamlAuth = async (email: string, callbackUrl = "/") => {
|
||||
try {
|
||||
const attributes = {
|
||||
email_domain: email,
|
||||
...(callbackUrl !== "/" && { callback_url: callbackUrl }),
|
||||
};
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/auth/saml/initiate/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -176,9 +181,7 @@ export const initiateSamlAuth = async (email: string) => {
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "saml-initiate",
|
||||
attributes: {
|
||||
email_domain: email,
|
||||
},
|
||||
attributes,
|
||||
},
|
||||
}),
|
||||
redirect: "manual",
|
||||
|
||||
@@ -13,6 +13,7 @@ const SignUp = async ({
|
||||
typeof resolvedSearchParams?.invitation_token === "string"
|
||||
? resolvedSearchParams.invitation_token
|
||||
: null;
|
||||
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
|
||||
const GOOGLE_AUTH_URL = getAuthUrl("google");
|
||||
const GITHUB_AUTH_URL = getAuthUrl("github");
|
||||
@@ -21,6 +22,7 @@ const SignUp = async ({
|
||||
<AuthForm
|
||||
type="sign-up"
|
||||
invitationToken={invitationToken}
|
||||
isCloudEnv={isCloudEnv}
|
||||
googleAuthUrl={GOOGLE_AUTH_URL}
|
||||
githubAuthUrl={GITHUB_AUTH_URL}
|
||||
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
|
||||
|
||||
@@ -3,15 +3,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { signIn } from "@/auth.config";
|
||||
import {
|
||||
getInvitationTokenFromCallbackPath,
|
||||
getSafeCallbackPath,
|
||||
} from "@/lib/auth-callback-url";
|
||||
import { apiBaseUrl, baseUrl } from "@/lib/helper";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const callbackPath = getSafeCallbackPath(searchParams);
|
||||
const invitationToken = getInvitationTokenFromCallbackPath(callbackPath);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("code", code || "");
|
||||
if (invitationToken) {
|
||||
params.append("invitation_token", invitationToken);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
@@ -37,18 +46,20 @@ export async function GET(req: Request) {
|
||||
const { access, refresh } = data.data.attributes;
|
||||
|
||||
try {
|
||||
// Invitation tokens are accepted during the social token exchange.
|
||||
const redirectPath = invitationToken ? "/" : callbackPath;
|
||||
const result = await signIn("social-oauth", {
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
redirect: false,
|
||||
callbackUrl: `${baseUrl}/`,
|
||||
callbackUrl: new URL(redirectPath, baseUrl).toString(),
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL("/", baseUrl));
|
||||
return NextResponse.redirect(new URL(redirectPath, baseUrl));
|
||||
} catch (error) {
|
||||
console.error("SignIn error:", error);
|
||||
return NextResponse.redirect(
|
||||
@@ -57,9 +68,8 @@ export async function GET(req: Request) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in Github callback:", error);
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 },
|
||||
return NextResponse.redirect(
|
||||
new URL("/sign-in?error=AuthenticationFailed", baseUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { signIn } from "@/auth.config";
|
||||
import {
|
||||
getInvitationTokenFromCallbackPath,
|
||||
getSafeCallbackPath,
|
||||
} from "@/lib/auth-callback-url";
|
||||
import { apiBaseUrl, baseUrl } from "@/lib/helper";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const callbackPath = getSafeCallbackPath(searchParams);
|
||||
const invitationToken = getInvitationTokenFromCallbackPath(callbackPath);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("code", code || "");
|
||||
if (invitationToken) {
|
||||
params.append("invitation_token", invitationToken);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
@@ -37,18 +46,20 @@ export async function GET(req: Request) {
|
||||
const { access, refresh } = data.data.attributes;
|
||||
|
||||
try {
|
||||
// Invitation tokens are accepted during the social token exchange.
|
||||
const redirectPath = invitationToken ? "/" : callbackPath;
|
||||
const result = await signIn("social-oauth", {
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
redirect: false,
|
||||
callbackUrl: `${baseUrl}/`,
|
||||
callbackUrl: new URL(redirectPath, baseUrl).toString(),
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL("/", baseUrl));
|
||||
return NextResponse.redirect(new URL(redirectPath, baseUrl));
|
||||
} catch (error) {
|
||||
console.error("SignIn error:", error);
|
||||
return NextResponse.redirect(
|
||||
@@ -57,9 +68,8 @@ export async function GET(req: Request) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in Google callback:", error);
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 },
|
||||
return NextResponse.redirect(
|
||||
new URL("/sign-in?error=AuthenticationFailed", baseUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { signIn } from "@/auth.config";
|
||||
import { getSafeCallbackPath } from "@/lib/auth-callback-url";
|
||||
import { apiBaseUrl, baseUrl } from "@/lib/helper";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get("id");
|
||||
const callbackPath = getSafeCallbackPath(searchParams, "callbackUrl");
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
@@ -40,14 +42,14 @@ export async function GET(req: Request) {
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
redirect: false,
|
||||
callbackUrl: `${baseUrl}/`,
|
||||
callbackUrl: new URL(callbackPath, baseUrl).toString(),
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL("/", baseUrl));
|
||||
return NextResponse.redirect(new URL(callbackPath, baseUrl));
|
||||
} catch (error) {
|
||||
console.error("SAML authentication failed:", error);
|
||||
return NextResponse.redirect(new URL("/sign-in", baseUrl));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SignUpForm } from "@/components/auth/oss/sign-up-form";
|
||||
export const AuthForm = ({
|
||||
type,
|
||||
invitationToken,
|
||||
isCloudEnv,
|
||||
googleAuthUrl,
|
||||
githubAuthUrl,
|
||||
isGoogleOAuthEnabled,
|
||||
@@ -11,6 +12,7 @@ export const AuthForm = ({
|
||||
}: {
|
||||
type: string;
|
||||
invitationToken?: string | null;
|
||||
isCloudEnv?: boolean;
|
||||
googleAuthUrl?: string;
|
||||
githubAuthUrl?: string;
|
||||
isGoogleOAuthEnabled?: boolean;
|
||||
@@ -30,6 +32,7 @@ export const AuthForm = ({
|
||||
return (
|
||||
<SignUpForm
|
||||
invitationToken={invitationToken}
|
||||
isCloudEnv={isCloudEnv}
|
||||
googleAuthUrl={googleAuthUrl}
|
||||
githubAuthUrl={githubAuthUrl}
|
||||
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { getSafeCallbackPath } from "@/lib/auth-callback-url";
|
||||
import { SignInFormData, signInSchema } from "@/types";
|
||||
|
||||
export const SignInForm = ({
|
||||
@@ -32,7 +33,7 @@ export const SignInForm = ({
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const callbackUrl = searchParams.get("callbackUrl") || "/";
|
||||
const callbackUrl = getSafeCallbackPath(searchParams, "callbackUrl");
|
||||
|
||||
useEffect(() => {
|
||||
const samlError = searchParams.get("sso_saml_failed");
|
||||
@@ -102,7 +103,7 @@ export const SignInForm = ({
|
||||
form.setValue("password", "");
|
||||
}
|
||||
|
||||
const result = await initiateSamlAuth(email);
|
||||
const result = await initiateSamlAuth(email, callbackUrl);
|
||||
|
||||
if (result.success && result.redirectUrl) {
|
||||
window.location.href = result.redirectUrl;
|
||||
@@ -181,6 +182,7 @@ export const SignInForm = ({
|
||||
<SocialButtons
|
||||
googleAuthUrl={googleAuthUrl}
|
||||
githubAuthUrl={githubAuthUrl}
|
||||
callbackUrl={callbackUrl}
|
||||
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
|
||||
isGithubOAuthEnabled={isGithubOAuthEnabled}
|
||||
/>
|
||||
|
||||
@@ -41,12 +41,14 @@ const FORM_ERROR_TYPE = {
|
||||
|
||||
export const SignUpForm = ({
|
||||
invitationToken,
|
||||
isCloudEnv,
|
||||
googleAuthUrl,
|
||||
githubAuthUrl,
|
||||
isGoogleOAuthEnabled,
|
||||
isGithubOAuthEnabled,
|
||||
}: {
|
||||
invitationToken?: string | null;
|
||||
isCloudEnv?: boolean;
|
||||
googleAuthUrl?: string;
|
||||
githubAuthUrl?: string;
|
||||
isGoogleOAuthEnabled?: boolean;
|
||||
@@ -54,6 +56,9 @@ export const SignUpForm = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const callbackUrl = invitationToken
|
||||
? `/invitation/accept?invitation_token=${encodeURIComponent(invitationToken)}`
|
||||
: "/";
|
||||
|
||||
const form = useForm<SignUpFormData>({
|
||||
resolver: zodResolver(signUpSchema),
|
||||
@@ -75,8 +80,14 @@ export const SignUpForm = ({
|
||||
name: "password",
|
||||
defaultValue: "",
|
||||
});
|
||||
const termsAccepted = useWatch({
|
||||
control: form.control,
|
||||
name: "termsAndConditions",
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
const isSocialAuthDisabled = Boolean(isCloudEnv && !termsAccepted);
|
||||
|
||||
const onSubmit = async (data: SignUpFormData) => {
|
||||
const newUser = await createNewUser(data);
|
||||
@@ -88,7 +99,7 @@ export const SignUpForm = ({
|
||||
});
|
||||
form.reset();
|
||||
|
||||
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
|
||||
if (isCloudEnv) {
|
||||
router.push("/email-verification");
|
||||
} else {
|
||||
router.push("/sign-in");
|
||||
@@ -200,7 +211,7 @@ export const SignUpForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && (
|
||||
{isCloudEnv && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="termsAndConditions"
|
||||
@@ -243,15 +254,18 @@ export const SignUpForm = ({
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{!invitationToken && (
|
||||
{(!invitationToken || isCloudEnv) && (
|
||||
<>
|
||||
<AuthDivider />
|
||||
<div className="flex flex-col gap-2">
|
||||
<SocialButtons
|
||||
googleAuthUrl={googleAuthUrl}
|
||||
githubAuthUrl={githubAuthUrl}
|
||||
callbackUrl={callbackUrl}
|
||||
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
|
||||
isGithubOAuthEnabled={isGithubOAuthEnabled}
|
||||
isDisabled={isSocialAuthDisabled}
|
||||
disabledTooltipContent="Accept the Terms of Service to continue."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,83 +1,153 @@
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { appendCallbackState } from "@/lib/auth-callback-url";
|
||||
|
||||
type SocialProvider = {
|
||||
key: string;
|
||||
label: string;
|
||||
url?: string;
|
||||
isOAuthEnabled?: boolean;
|
||||
enabledIcon: string;
|
||||
disabledIcon: string;
|
||||
disabledDocs: {
|
||||
message: string;
|
||||
href: string;
|
||||
};
|
||||
};
|
||||
|
||||
const SocialButton = ({
|
||||
provider,
|
||||
isDisabled,
|
||||
disabledTooltipContent,
|
||||
}: {
|
||||
provider: SocialProvider;
|
||||
isDisabled: boolean;
|
||||
disabledTooltipContent: ReactNode;
|
||||
}) => {
|
||||
const button = (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
asChild={!isDisabled}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon
|
||||
icon={
|
||||
provider.isOAuthEnabled
|
||||
? provider.enabledIcon
|
||||
: provider.disabledIcon
|
||||
}
|
||||
width={24}
|
||||
/>
|
||||
{provider.label}
|
||||
</span>
|
||||
) : (
|
||||
<a href={provider.url} className="flex items-center gap-2">
|
||||
<Icon icon={provider.enabledIcon} width={24} />
|
||||
{provider.label}
|
||||
</a>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!isDisabled) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex w-full">{button}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="w-96">
|
||||
{provider.isOAuthEnabled ? (
|
||||
disabledTooltipContent
|
||||
) : (
|
||||
<div className="flex-inline text-small">
|
||||
{provider.disabledDocs.message}{" "}
|
||||
<CustomLink href={provider.disabledDocs.href}>
|
||||
Read the docs
|
||||
</CustomLink>
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const SocialButtons = ({
|
||||
googleAuthUrl,
|
||||
githubAuthUrl,
|
||||
callbackUrl = "/",
|
||||
isGoogleOAuthEnabled,
|
||||
isGithubOAuthEnabled,
|
||||
isDisabled = false,
|
||||
disabledTooltipContent,
|
||||
}: {
|
||||
googleAuthUrl?: string;
|
||||
githubAuthUrl?: string;
|
||||
callbackUrl?: string;
|
||||
isGoogleOAuthEnabled?: boolean;
|
||||
isGithubOAuthEnabled?: boolean;
|
||||
}) => (
|
||||
<>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex-inline text-small">
|
||||
Social Login with Google is not enabled.{" "}
|
||||
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#google-oauth-configuration">
|
||||
Read the docs
|
||||
</CustomLink>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
shadow="sm"
|
||||
isDisabled={isGoogleOAuthEnabled}
|
||||
className="w-96"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
asChild={isGoogleOAuthEnabled}
|
||||
disabled={!isGoogleOAuthEnabled}
|
||||
>
|
||||
<a href={googleAuthUrl} className="flex items-center gap-2">
|
||||
<Icon
|
||||
icon={
|
||||
isGoogleOAuthEnabled
|
||||
? "flat-color-icons:google"
|
||||
: "simple-icons:google"
|
||||
}
|
||||
width={24}
|
||||
/>
|
||||
Continue with Google
|
||||
</a>
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex-inline text-small">
|
||||
Social Login with Github is not enabled.{" "}
|
||||
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#github-oauth-configuration">
|
||||
Read the docs
|
||||
</CustomLink>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
shadow="sm"
|
||||
isDisabled={isGithubOAuthEnabled}
|
||||
className="w-96"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
asChild={isGithubOAuthEnabled}
|
||||
disabled={!isGithubOAuthEnabled}
|
||||
>
|
||||
<a href={githubAuthUrl} className="flex items-center gap-2">
|
||||
<Icon icon="simple-icons:github" width={24} />
|
||||
Continue with Github
|
||||
</a>
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
isDisabled?: boolean;
|
||||
disabledTooltipContent?: ReactNode;
|
||||
}) => {
|
||||
const googleUrl = googleAuthUrl
|
||||
? appendCallbackState(googleAuthUrl, callbackUrl)
|
||||
: undefined;
|
||||
const githubUrl = githubAuthUrl
|
||||
? appendCallbackState(githubAuthUrl, callbackUrl)
|
||||
: undefined;
|
||||
const socialDisabledTooltip =
|
||||
disabledTooltipContent || "Social login is currently unavailable.";
|
||||
|
||||
const providers: SocialProvider[] = [
|
||||
{
|
||||
key: "google",
|
||||
label: "Continue with Google",
|
||||
url: googleUrl,
|
||||
isOAuthEnabled: isGoogleOAuthEnabled,
|
||||
enabledIcon: "flat-color-icons:google",
|
||||
disabledIcon: "simple-icons:google",
|
||||
disabledDocs: {
|
||||
message: "Social Login with Google is not enabled.",
|
||||
href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#google-oauth-configuration",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "github",
|
||||
label: "Continue with Github",
|
||||
url: githubUrl,
|
||||
isOAuthEnabled: isGithubOAuthEnabled,
|
||||
enabledIcon: "simple-icons:github",
|
||||
disabledIcon: "simple-icons:github",
|
||||
disabledDocs: {
|
||||
message: "Social Login with Github is not enabled.",
|
||||
href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#github-oauth-configuration",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{providers.map((provider) => (
|
||||
<SocialButton
|
||||
key={provider.key}
|
||||
provider={provider}
|
||||
isDisabled={isDisabled || !provider.isOAuthEnabled || !provider.url}
|
||||
disabledTooltipContent={socialDisabledTooltip}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
appendCallbackState,
|
||||
getInvitationTokenFromCallbackPath,
|
||||
getSafeCallbackPath,
|
||||
} from "@/lib/auth-callback-url";
|
||||
|
||||
describe("auth callback URL helpers", () => {
|
||||
describe("when appending OAuth state", () => {
|
||||
it("should add a relative callback path as provider state", () => {
|
||||
const authUrl = "https://accounts.example.com/oauth?client_id=client";
|
||||
const callbackPath = "/invitation/accept?invitation_token=test-token";
|
||||
|
||||
const result = appendCallbackState(authUrl, callbackPath);
|
||||
|
||||
expect(new URL(result).searchParams.get("state")).toBe(callbackPath);
|
||||
});
|
||||
|
||||
it("should not add state for the default callback path", () => {
|
||||
const authUrl = "https://accounts.example.com/oauth?client_id=client";
|
||||
|
||||
const result = appendCallbackState(authUrl, "/");
|
||||
|
||||
expect(new URL(result).searchParams.has("state")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when reading callback paths", () => {
|
||||
it("should return relative callback paths", () => {
|
||||
const params = new URLSearchParams({
|
||||
state: "/invitation/accept?invitation_token=test-token",
|
||||
});
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe("/invitation/accept?invitation_token=test-token");
|
||||
});
|
||||
|
||||
it("should reject external callback URLs", () => {
|
||||
const params = new URLSearchParams({
|
||||
state: "https://attacker.example/phishing",
|
||||
});
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe("/");
|
||||
});
|
||||
|
||||
it("should reject protocol-relative callback URLs", () => {
|
||||
const params = new URLSearchParams({
|
||||
state: "//attacker.example/phishing",
|
||||
});
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe("/");
|
||||
});
|
||||
|
||||
it("should reject backslash-normalized callback URLs", () => {
|
||||
const params = new URLSearchParams({ state: "/\\attacker.example" });
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe("/");
|
||||
});
|
||||
|
||||
it("should reject callback URLs with control characters before the host", () => {
|
||||
const params = new URLSearchParams({ state: "/\t/attacker.example" });
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe("/");
|
||||
});
|
||||
|
||||
it("should preserve the query string of relative callback paths", () => {
|
||||
const params = new URLSearchParams({
|
||||
state: "/invitation/accept?invitation_token=test-token&foo=bar",
|
||||
});
|
||||
|
||||
const result = getSafeCallbackPath(params);
|
||||
|
||||
expect(result).toBe(
|
||||
"/invitation/accept?invitation_token=test-token&foo=bar",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when appending OAuth state for unsafe paths", () => {
|
||||
it("should not add a backslash-normalized path as provider state", () => {
|
||||
const authUrl = "https://accounts.example.com/oauth?client_id=client";
|
||||
|
||||
const result = appendCallbackState(authUrl, "/\\attacker.example");
|
||||
|
||||
expect(new URL(result).searchParams.has("state")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when reading invitation tokens", () => {
|
||||
it("should return invitation tokens from safe callback paths", () => {
|
||||
const callbackPath = "/invitation/accept?invitation_token=test-token";
|
||||
|
||||
const result = getInvitationTokenFromCallbackPath(callbackPath);
|
||||
|
||||
expect(result).toBe("test-token");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const DEFAULT_CALLBACK_PATH = "/";
|
||||
const INVITATION_TOKEN_PARAM = "invitation_token";
|
||||
// Origin used only to resolve relative paths; never part of the returned value.
|
||||
const INTERNAL_ORIGIN = "http://localhost";
|
||||
|
||||
type CallbackSearchParams = {
|
||||
get(name: string): string | null;
|
||||
};
|
||||
|
||||
export const getSafeCallbackPathFromValue = (
|
||||
value: string | null | undefined,
|
||||
) => {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
|
||||
// A prefix check is not enough: the URL parser normalizes backslashes and
|
||||
// control characters, so "/\evil.com" or "/\t/evil.com" pass the check above
|
||||
// yet resolve to an external origin. Resolve against a fixed origin and
|
||||
// confirm it stayed internal before trusting the path.
|
||||
try {
|
||||
const url = new URL(value, INTERNAL_ORIGIN);
|
||||
if (url.origin !== INTERNAL_ORIGIN) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
} catch (_error) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSafeCallbackPath = (
|
||||
searchParams: CallbackSearchParams,
|
||||
key = "state",
|
||||
) => getSafeCallbackPathFromValue(searchParams.get(key));
|
||||
|
||||
export const appendCallbackState = (authUrl: string, callbackPath: string) => {
|
||||
const safeCallbackPath = getSafeCallbackPathFromValue(callbackPath);
|
||||
if (safeCallbackPath === DEFAULT_CALLBACK_PATH) {
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(authUrl);
|
||||
url.searchParams.set("state", safeCallbackPath);
|
||||
return url.toString();
|
||||
} catch (_error) {
|
||||
return authUrl;
|
||||
}
|
||||
};
|
||||
|
||||
export const getInvitationTokenFromCallbackPath = (callbackPath: string) => {
|
||||
const safeCallbackPath = getSafeCallbackPathFromValue(callbackPath);
|
||||
if (safeCallbackPath === DEFAULT_CALLBACK_PATH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(safeCallbackPath, "http://localhost");
|
||||
return url.searchParams.get(INVITATION_TOKEN_PARAM);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user