Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/e2e/tests/js/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ it("adds provider_scope from oauthScopesOnSignIn for authenticate flow", async (
},
{
client: {
redirectMethod: "window",
oauthScopesOnSignIn: {
github: ["repo"],
},
Expand Down Expand Up @@ -52,4 +53,56 @@ it("adds provider_scope from oauthScopesOnSignIn for authenticate flow", async (
expect(scope).toBe("user:email repo");
}, { timeout: 40_000 });

it("does not resolve signInWithOAuth after a custom redirectMethod starts navigation", async ({ expect }) => {
const navigatedUrls: string[] = [];
const { clientApp } = await createApp(
{
config: {
oauthProviders: [
{
id: "github",
type: "standard",
clientId: "test_client_id",
clientSecret: "test_client_secret",
},
],
},
},
{
client: {
redirectMethod: {
useNavigate: () => (url) => {
navigatedUrls.push(url);
},
navigate: (url) => {
navigatedUrls.push(url);
},
},
},
}
);

const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
globalThis.document = { cookie: "", createElement: () => ({}) } as any;
globalThis.window = {
location: {
href: localRedirectUrl,
},
} as any;

try {
const redirectResult = clientApp.signInWithOAuth("github").then(() => "resolved");
const result = await Promise.race([
redirectResult,
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 5000)),
]);

expect(navigatedUrls).toHaveLength(1);
expect(new URL(navigatedUrls[0]).pathname).toBe("/login/oauth/authorize");
expect(result).toBe("pending");
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;
}
}, { timeout: 40_000 });
2 changes: 1 addition & 1 deletion packages/stack-shared/src/interface/handler-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type HandlerPageUrls = Record<
| "magicLinkCallback"
| "accountSettings"
| "teamInvitation"
| "cliAuthConfirm"
| "mfa"
| "error"
| "onboarding",
Expand Down Expand Up @@ -45,4 +46,3 @@ export {
type PageVersionEntry,
type PageVersions
} from "./page-component-versions";

53 changes: 53 additions & 0 deletions packages/stack-shared/src/interface/page-component-versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,59 @@ export function getCustomPagePrompts(): Record<PageComponentKey, CustomPagePromp
`,
versions: {},
}),
cliAuthConfirm: createCustomPagePrompt({
key: "cliAuthConfirm",
title: "CLI Auth Confirmation",
minSdkVersion: "0.0.1",
structure: deindent`
- Use \`useCliAuthConfirmation()\`.
- If \`status === "invalid"\`, show an invalid-link state.
- If \`status === "success"\`, tell the user they can close the browser and return to the CLI.
- If \`status === "error"\`, show the error and a retry action.
- Otherwise, show a confirmation step that calls \`authorize()\`.
- Use \`isLoading\` to disable or show loading on the confirmation action while the hook is authorizing or redirecting.
`,
reactExample: deindent`
export default function CustomCliAuthConfirmPage() {
const cliAuth = useCliAuthConfirmation();

if (cliAuth.status === "invalid") {
return <MessageCard title="Invalid CLI authorization link" />;
}

if (cliAuth.status === "success") {
return <MessageCard title="CLI authorized">You can close this window and return to the command line.</MessageCard>;
}

if (cliAuth.status === "error") {
return (
<MessageCard
title="CLI authorization failed"
primaryButtonText="Try again"
primaryAction={cliAuth.retry}
>
{cliAuth.error?.message}
</MessageCard>
);
}

return (
<MessageCard
title="Authorize CLI application"
primaryButtonText={cliAuth.isLoading ? "Authorizing..." : "Authorize"}
primaryAction={cliAuth.authorize}
>
A command line application is requesting access to your account.
</MessageCard>
);
}
`,
notes: deindent`
- Be explicit about the account being authorized. CLI auth grants a refresh token to the command line application.
- The hook owns the protocol details: reading \`login_code\`, preserving confirmed state across redirects, claiming anonymous sessions, and completing authorization.
`,
versions: {},
}),
mfa: createCustomPagePrompt({
key: "mfa",
title: "MFA",
Expand Down
18 changes: 14 additions & 4 deletions packages/template/src/components-page/oauth-callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,27 @@ import { useEffect, useRef, useState } from "react";
import { useStackApp } from "..";
import { MaybeFullPage } from "../components/elements/maybe-full-page";
import { StyledLink } from "../components/link";
import { stackAppInternalsSymbol } from "../lib/stack-app";
import { useTranslation } from "../lib/translations";

export function OAuthCallback({ fullPage }: { fullPage?: boolean }) {
const { t } = useTranslation();
const app = useStackApp();
const called = useRef(false);
const [showRedirectLink, setShowRedirectLink] = useState(false);
const [redirectUrl, setRedirectUrl] = useState<string | null>(null);

useEffect(() => runAsynchronously(async () => {
if (called.current) return;
called.current = true;
const redirectToError = async (url: URL) => {
const urlString = url.toString();
if (app[stackAppInternalsSymbol].getRedirectMethod() === "none") {
setRedirectUrl(urlString);
return;
}
await app[stackAppInternalsSymbol].redirectToUrl(urlString, { replace: true });
};
try {
const hasRedirected = await app.callOAuthCallback();
if (!hasRedirected) {
Expand All @@ -30,13 +40,13 @@ export function OAuthCallback({ fullPage }: { fullPage?: boolean }) {
errorUrl.searchParams.set("errorCode", e.errorCode);
errorUrl.searchParams.set("message", e.message);
errorUrl.searchParams.set("details", JSON.stringify(e.details ?? {}));
window.location.replace(errorUrl.toString());
await redirectToError(errorUrl);
return;
}
captureError("<OAuthCallback />", e);
window.location.replace(new URL(app.urls.error, window.location.href).toString());
await redirectToError(new URL(app.urls.error, window.location.href));
}
}), []);
}), [app]);

useEffect(() => {
setTimeout(() => setShowRedirectLink(true), 3000);
Expand All @@ -56,7 +66,7 @@ export function OAuthCallback({ fullPage }: { fullPage?: boolean }) {
<div className="flex flex-col justify-center items-center gap-4">
<Spinner size={20} />
</div>
{showRedirectLink ? <p>{t('If you are not redirected automatically, ')}<StyledLink className="whitespace-nowrap" href={app.urls.home}>{t("click here")}</StyledLink></p> : null}
{showRedirectLink || redirectUrl != null ? <p>{t('If you are not redirected automatically, ')}<StyledLink className="whitespace-nowrap" href={redirectUrl ?? app.urls.home}>{t("click here")}</StyledLink></p> : null}
</div>
</MaybeFullPage>
);
Expand Down
40 changes: 37 additions & 3 deletions packages/template/src/components-page/stack-handler-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { FilterUndefined, filterUndefined } from "@stackframe/stack-shared/dist/
import { getRelativePart } from "@stackframe/stack-shared/dist/utils/urls";
import { notFound, redirect, RedirectType, usePathname, useSearchParams } from 'next/navigation'; // THIS_LINE_PLATFORM next
import { useMemo } from 'react';
/* IF_PLATFORM react
import { useEffect, useRef } from 'react';
// END_PLATFORM */
import { SignIn, SignUp, StackServerApp } from "..";
import { useStackApp } from "../lib/hooks";
import { HandlerUrls, StackClientApp, stackAppInternalsSymbol } from "../lib/stack-app";
Expand Down Expand Up @@ -229,8 +232,12 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
const currentLocation = pathname;
const searchParamsSource = searchParamsFromHook;
/* ELSE_IF_PLATFORM react
const navigate = stackApp.useNavigate();
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
const currentLocation = props.location ?? window.location.pathname;
const searchParamsSource = new URLSearchParams(window.location.search);
const redirectTargets: (string | undefined)[] = [];
END_PLATFORM */

const { path, searchParams, handlerPath } = useMemo(() => {
Expand Down Expand Up @@ -277,7 +284,7 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
// IF_PLATFORM next
redirect(toAbsoluteOrRelativeRedirectTarget(urlObj), RedirectType.replace);
/* ELSE_IF_PLATFORM react
window.location.href = toAbsoluteOrRelativeRedirectTarget(urlObj);
redirectTargets.push(toAbsoluteOrRelativeRedirectTarget(urlObj));
END_PLATFORM */
};

Expand Down Expand Up @@ -311,11 +318,38 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
// IF_PLATFORM next
redirect(result.redirect, RedirectType.replace);
/* ELSE_IF_PLATFORM react
window.location.href = result.redirect;
return null;
redirectTargets.push(result.redirect);
END_PLATFORM */
}

/* IF_PLATFORM react
const redirectTarget = redirectTargets[0];
const shouldRenderRedirectFallback = redirectTarget != null && stackApp[stackAppInternalsSymbol].getRedirectMethod() === "none";
useEffect(() => {
if (redirectTarget == null || shouldRenderRedirectFallback) {
return;
}
navigateRef.current(redirectTarget);
}, [redirectTarget, shouldRenderRedirectFallback]);

if (redirectTarget != null && shouldRenderRedirectFallback) {
return (
<MessageCard
title="Continue"
fullPage={props.fullPage}
primaryButtonText="Continue"
primaryAction={() => window.location.assign(redirectTarget)}
>
Continue to the next page.
</MessageCard>
);
}

Comment thread
mantrakp04 marked this conversation as resolved.
if (redirectTarget != null) {
return null;
}
END_PLATFORM */

return result;
}

Expand Down
63 changes: 63 additions & 0 deletions packages/template/src/lib/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// @vitest-environment jsdom

import { StackClientInterface } from "@stackframe/stack-shared";
import { describe, expect, it, vi } from "vitest";
import { getNewOAuthProviderOrScopeUrl } from "./auth";

vi.mock("./cookie", async (importOriginal) => {
const actual = await importOriginal<typeof import("./cookie")>();
return {
...actual,
saveVerifierAndState: async () => ({
codeChallenge: "<stripped code challenge>",
state: "<stripped state>",
}),
};
});

describe("getNewOAuthProviderOrScopeUrl", () => {
it("returns the OAuth URL without performing navigation", async () => {
window.history.replaceState({}, "", "/account?after_auth_return_to=%2Fsettings");

const iface = new StackClientInterface({
clientVersion: "test",
getBaseUrl: () => "https://api.example.com",
getApiUrls: () => ["https://api.example.com"],
extraRequestHeaders: {},
projectId: "00000000-0000-4000-8000-000000000000",
publishableClientKey: "pck_test",
});
const session = iface.createSession({ refreshToken: null, accessToken: null });

const location = await getNewOAuthProviderOrScopeUrl(
iface,
{
provider: "github",
redirectUrl: "/handler/oauth-callback",
errorRedirectUrl: "/handler/error",
providerScope: "repo user",
},
session,
);

const url = new URL(location);
expect(`${url.origin}${url.pathname}`).toBe("https://api.example.com/api/v1/auth/oauth/authorize/github");
expect(Object.fromEntries(url.searchParams.entries())).toMatchInlineSnapshot(`
{
"after_callback_redirect_url": "http://localhost:3000/account?after_auth_return_to=%2Fsettings",
"client_id": "00000000-0000-4000-8000-000000000000",
"client_secret": "pck_test",
"code_challenge": "<stripped code challenge>",
"code_challenge_method": "S256",
"error_redirect_url": "http://localhost:3000/handler/error?after_auth_return_to=%2Fsettings",
"grant_type": "authorization_code",
"provider_scope": "repo user",
"redirect_uri": "http://localhost:3000/handler/oauth-callback?after_auth_return_to=%2Fsettings",
"response_type": "code",
"scope": "legacy",
"state": "<stripped state>",
"type": "link",
}
`);
});
});
9 changes: 3 additions & 6 deletions packages/template/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { KnownError, StackClientInterface } from "@stackframe/stack-shared";
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { neverResolve } from "@stackframe/stack-shared/dist/utils/promises";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import { constructRedirectUrl } from "../utils/url";
import { consumeVerifierAndStateCookie, saveVerifierAndState } from "./cookie";
export async function addNewOAuthProviderOrScope(
export async function getNewOAuthProviderOrScopeUrl(
iface: StackClientInterface,
options: {
provider: string,
Expand All @@ -15,9 +14,9 @@ export async function addNewOAuthProviderOrScope(
providerScope?: string,
},
session: InternalSession,
) {
): Promise<string> {
const { codeChallenge, state } = await saveVerifierAndState();
const location = await iface.getOAuthUrl({
return await iface.getOAuthUrl({
provider: options.provider,
redirectUrl: constructRedirectUrl(options.redirectUrl, "redirectUrl"),
errorRedirectUrl: constructRedirectUrl(options.errorRedirectUrl, "errorRedirectUrl"),
Expand All @@ -28,8 +27,6 @@ export async function addNewOAuthProviderOrScope(
session,
providerScope: options.providerScope,
});
window.location.assign(location);
await neverResolve();
}

/**
Expand Down
Loading
Loading