Merge branch 'dev' into promptless/document-cli-auth-confirmation-hook

This commit is contained in:
promptless[bot] 2026-04-28 23:35:28 +00:00
commit 6757cce94c
8 changed files with 197 additions and 26 deletions

View File

@ -17,6 +17,7 @@ it("adds provider_scope from oauthScopesOnSignIn for authenticate flow", async (
},
{
client: {
redirectMethod: "window",
oauthScopesOnSignIn: {
github: ["repo"],
},
@ -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 });

View File

@ -8,6 +8,7 @@ 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 }) {
@ -15,10 +16,19 @@ export function OAuthCallback({ fullPage }: { fullPage?: boolean }) {
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) {
@ -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);
@ -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>
);

View File

@ -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";
@ -230,8 +233,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(() => {
@ -278,7 +285,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 */
};
@ -312,11 +319,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>
);
}
if (redirectTarget != null) {
return null;
}
END_PLATFORM */
return result;
}

View File

@ -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",
}
`);
});
});

View File

@ -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,
@ -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"),
@ -28,8 +27,6 @@ export async function addNewOAuthProviderOrScope(
session,
providerScope: options.providerScope,
});
window.location.assign(location);
await neverResolve();
}
/**

View File

@ -41,7 +41,7 @@ import * as NextNavigationUnscrambled from "next/navigation"; // import the enti
import React, { useCallback, useMemo } from "react"; // THIS_LINE_PLATFORM react-like
import type * as yup from "yup";
import { constructRedirectUrl } from "../../../../utils/url";
import { addNewOAuthProviderOrScope, callOAuthCallback } from "../../../auth";
import { getNewOAuthProviderOrScopeUrl, callOAuthCallback } from "../../../auth";
import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookie, deleteCookieClient, isSecure as isSecureCookieContext, saveVerifierAndState, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie";
import { envVars } from "../../../env";
import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud } from "../../api-keys";
@ -220,7 +220,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}
}
await addNewOAuthProviderOrScope(
const location = await getNewOAuthProviderOrScopeUrl(
this._interface,
{
provider,
@ -230,6 +230,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
},
session,
);
await this._redirectTo({ url: location });
return await neverResolve();
}
);
@ -423,7 +424,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
Often, you can solve this by calling this function in the browser instead, or by removing the 'or: redirect' option and dealing with the case where the user doesn't have enough permissions.
`);
}
await addNewOAuthProviderOrScope(
const location = await getNewOAuthProviderOrScopeUrl(
this._interface,
{
provider: options.providerId,
@ -433,6 +434,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
},
options.session,
);
await this._redirectTo({ url: location });
return await neverResolve();
} else if (!hasConnection) {
return null;
@ -1677,7 +1679,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
// END_PLATFORM
async linkConnectedAccount(provider: string, options?: { scopes?: string[] }) {
const scopeString = options?.scopes?.join(" ") ?? "";
await addNewOAuthProviderOrScope(
const location = await getNewOAuthProviderOrScopeUrl(
app._interface,
{
provider,
@ -1687,8 +1689,8 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
},
session,
);
// This won't actually be reached since addNewOAuthProviderOrScope redirects
await neverResolve();
await app._redirectTo({ url: location });
return await neverResolve();
},
async getOrLinkConnectedAccount(provider: string, options?: { scopes?: string[] }) {
const connectedAccounts = Result.orThrow(await app._currentUserConnectedAccountsCache.getOrWait([session], "write-only"));
@ -2796,16 +2798,20 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
this._ensurePersistentTokenStore();
const session = await this._getSession();
const currentUrl = new URL(window.location.href);
const afterCallbackRedirectUrl = currentUrl.searchParams.has("after_auth_return_to")
? currentUrl.toString()
: undefined;
const afterCallbackRedirectUrl = options?.returnTo != null
? constructRedirectUrl(options.returnTo, "returnTo")
: (
currentUrl.searchParams.has("after_auth_return_to")
? currentUrl.toString()
: undefined
);
const siteKeys = this._getBotChallengeSiteKeys();
const { codeChallenge, state } = await saveVerifierAndState();
const executeOAuth = async (challenge: { token?: string, phase?: "invisible" | "visible", unavailable?: true }) => {
return await this._interface.authorizeOAuth({
provider,
redirectUrl: constructRedirectUrl(options?.returnTo ?? this.urls.oauthCallback, "redirectUrl"),
redirectUrl: constructRedirectUrl(this.urls.oauthCallback, "redirectUrl"),
errorRedirectUrl: constructRedirectUrl(this.urls.error, "errorRedirectUrl"),
afterCallbackRedirectUrl,
type: "authenticate",
@ -2844,7 +2850,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}
const location = Result.orThrow(authorizeResult);
window.location.assign(location);
await this._redirectTo({ url: location });
await neverResolve();
}
@ -3475,6 +3481,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
) => {
return await this._interface.sendClientRequest(path, requestOptions, await this._getSession(), requestType);
},
getRedirectMethod: () => this._redirectMethod ?? throwErr("Redirect method should have been initialized in the Stack client app constructor"),
redirectToUrl: async (url: string | URL, options?: { replace?: boolean }) => {
await this._redirectTo({ url, ...options });
},
refreshOwnedProjects: async () => {
await this._refreshOwnedProjects(await this._getSession());
},

View File

@ -118,6 +118,8 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
sendAnalyticsEventBatch(body: string, options: { keepalive: boolean }): Promise<Result<Response, Error>>,
addRequestListener(listener: RequestListener): () => void,
sendRequest(path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin"): Promise<Response>,
getRedirectMethod(): RedirectMethod,
redirectToUrl(url: string | URL, options?: { replace?: boolean }): Promise<void>,
signInWithTokens(tokens: { accessToken: string, refreshToken: string }): Promise<void>,
},
}

View File

@ -52,6 +52,7 @@ Use an OAuth library (e.g., oauth4webapi) to handle PKCE and state management.
Arguments:
provider: string - OAuth provider ID (e.g., "google", "github", "microsoft")
options.returnTo: string [BROWSER-ONLY, optional] - URL to redirect to after the OAuth callback completes
options.presentationContextProvider: platform-specific [NATIVE-ONLY]
- iOS/macOS: ASWebAuthenticationPresentationContextProviding
@ -66,7 +67,8 @@ Note: Additional provider scopes are configured via oauthScopesOnSignIn construc
Implementation:
1. Construct full redirect URLs using a fixed callback scheme:
- Native apps: "stack-auth-mobile-oauth-url://success" and "stack-auth-mobile-oauth-url://error"
- Browser: Use window.location to construct absolute URLs
- Browser: Use the configured OAuth callback handler URL as redirect_uri and window.location to construct absolute URLs
- Browser: If options.returnTo is provided, pass it as afterCallbackRedirectUrl, not as redirect_uri
2. Call getOAuthUrl() with the constructed URLs to get:
- Authorization URL
@ -79,7 +81,7 @@ Implementation:
- Mobile/other: in-memory (passed directly to callback handler)
4. Open the authorization URL:
- Browser: window.location.assign(authorization_url)
- Browser: perform redirect according to redirectMethod
- iOS/macOS: ASWebAuthenticationSession with callbackURLScheme: "stack-auth-mobile-oauth-url"
- Android: Custom Tabs with callback URL registered as deep link
- Desktop: Open system browser with registered URL scheme for callback