mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-21 21:09:49 +08:00
Sync suggestion branch with base branch
This commit is contained in:
commit
c2e95c349b
@ -46,12 +46,23 @@ async function createProjectUserOAuthAccountForLink(prisma: PrismaClientTransact
|
||||
});
|
||||
}
|
||||
|
||||
const redirectOrThrowError = (error: KnownError, tenancy: Tenancy, errorRedirectUrl?: string) => {
|
||||
if (!errorRedirectUrl || (!validateRedirectUrl(errorRedirectUrl, tenancy) && !isAcceptedNativeAppUrl(errorRedirectUrl))) {
|
||||
const redirectOrThrowError = (error: KnownError, tenancy: Tenancy, options: {
|
||||
oauthCallbackRedirectUrl?: string,
|
||||
errorRedirectUrl?: string,
|
||||
}) => {
|
||||
const targetRedirectUrl =
|
||||
options.oauthCallbackRedirectUrl && (validateRedirectUrl(options.oauthCallbackRedirectUrl, tenancy) || isAcceptedNativeAppUrl(options.oauthCallbackRedirectUrl))
|
||||
? options.oauthCallbackRedirectUrl
|
||||
: options.errorRedirectUrl && (validateRedirectUrl(options.errorRedirectUrl, tenancy) || isAcceptedNativeAppUrl(options.errorRedirectUrl))
|
||||
? options.errorRedirectUrl
|
||||
: null;
|
||||
if (!targetRedirectUrl) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = new URL(errorRedirectUrl);
|
||||
const url = new URL(targetRedirectUrl);
|
||||
url.searchParams.set("error", "server_error");
|
||||
url.searchParams.set("error_description", error.message);
|
||||
url.searchParams.set("errorCode", error.errorCode);
|
||||
url.searchParams.set("message", error.message);
|
||||
url.searchParams.set("details", error.details ? JSON.stringify(error.details) : JSON.stringify({}));
|
||||
@ -113,6 +124,7 @@ const handler = createSmartRouteHandler({
|
||||
projectUserId,
|
||||
providerScope,
|
||||
errorRedirectUrl,
|
||||
redirectUri,
|
||||
afterCallbackRedirectUrl,
|
||||
} = outerInfo;
|
||||
|
||||
@ -152,7 +164,7 @@ const handler = createSmartRouteHandler({
|
||||
});
|
||||
} catch (error) {
|
||||
if (KnownErrors['OAuthProviderAccessDenied'].isInstance(error)) {
|
||||
redirectOrThrowError(error, tenancy, errorRedirectUrl);
|
||||
redirectOrThrowError(error, tenancy, { oauthCallbackRedirectUrl: redirectUri, errorRedirectUrl });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@ -387,7 +399,7 @@ const handler = createSmartRouteHandler({
|
||||
return oauthResponseToSmartResponse(oauthResponse);
|
||||
} catch (error) {
|
||||
if (KnownError.isKnownError(error)) {
|
||||
redirectOrThrowError(error, tenancy, errorRedirectUrl);
|
||||
redirectOrThrowError(error, tenancy, { oauthCallbackRedirectUrl: redirectUri, errorRedirectUrl });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@ -31,4 +31,24 @@ describe("isRetryableOAuthUserInfoError", () => {
|
||||
message: "client credentials are invalid",
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for provider temporary-unavailability errors", () => {
|
||||
expect(isRetryableOAuthUserInfoError({
|
||||
error: "temporarily_unavailable",
|
||||
message: "provider is temporarily unavailable",
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for HTTP 5xx and 429 response statuses", () => {
|
||||
expect(isRetryableOAuthUserInfoError({
|
||||
response: {
|
||||
status: 503,
|
||||
},
|
||||
})).toBe(true);
|
||||
expect(isRetryableOAuthUserInfoError({
|
||||
response: {
|
||||
status: 429,
|
||||
},
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,6 +17,11 @@ const RETRYABLE_OAUTH_NETWORK_ERROR_CODES = new Set([
|
||||
"UND_ERR_HEADERS_TIMEOUT",
|
||||
"UND_ERR_BODY_TIMEOUT",
|
||||
]);
|
||||
const RETRYABLE_OAUTH_PROVIDER_ERROR_CODES = new Set([
|
||||
"server_error",
|
||||
"temporarily_unavailable",
|
||||
"timeout",
|
||||
]);
|
||||
|
||||
export type TokenSet = {
|
||||
accessToken: string,
|
||||
@ -40,17 +45,36 @@ function getUnknownProperty(obj: unknown, key: string): unknown {
|
||||
return Reflect.get(obj, key);
|
||||
}
|
||||
|
||||
function getNumberProperty(obj: unknown, key: string): number | undefined {
|
||||
if (typeof obj !== "object" || obj === null || !(key in obj)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Reflect.get(obj, key);
|
||||
return typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
export function isRetryableOAuthUserInfoError(error: unknown): boolean {
|
||||
const code = getStringProperty(error, "code");
|
||||
if (code && RETRYABLE_OAUTH_NETWORK_ERROR_CODES.has(code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const providerErrorCode = getStringProperty(error, "error")?.toLowerCase();
|
||||
if (providerErrorCode && RETRYABLE_OAUTH_PROVIDER_ERROR_CODES.has(providerErrorCode)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const name = getStringProperty(error, "name");
|
||||
if (name === "AbortError" || name === "TimeoutError") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = getUnknownProperty(error, "response");
|
||||
const responseStatus = getNumberProperty(response, "status");
|
||||
if (responseStatus === 429 || (responseStatus != null && responseStatus >= 500)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = getStringProperty(error, "message")?.toLowerCase();
|
||||
if (message?.includes("outgoing request timed out")) {
|
||||
return true;
|
||||
@ -235,6 +259,14 @@ export abstract class OAuthBaseProvider {
|
||||
if (error?.error === 'invalid_client') {
|
||||
throw new StatusError(400, `Invalid client credentials for this OAuth provider. Please ensure the configuration in the Stack Auth dashboard is correct.`);
|
||||
}
|
||||
if (isRetryableOAuthUserInfoError(error)) {
|
||||
captureError("inner-oauth-callback-retryable-error", new StackAssertionError("Transient OAuth provider failure during callback exchange.", {
|
||||
provider: this.constructor.name,
|
||||
params,
|
||||
cause: error,
|
||||
}));
|
||||
throw new KnownErrors.OAuthProviderTemporarilyUnavailable();
|
||||
}
|
||||
if (error?.error === 'unauthorized_scope_error') {
|
||||
const scopeMatch = error?.error_description?.match(/Scope "([^&]+)" is not authorized for your application/);
|
||||
const missingScope = scopeMatch ? scopeMatch[1] : null;
|
||||
@ -262,11 +294,12 @@ export abstract class OAuthBaseProvider {
|
||||
});
|
||||
|
||||
if (userInfoResult.status === "error") {
|
||||
throw new StackAssertionError("Failed to fetch OAuth user info after retries.", {
|
||||
captureError("oauth-userinfo-retry-exhausted", new StackAssertionError("Failed to fetch OAuth user info after retries.", {
|
||||
attempts: userInfoResult.attempts,
|
||||
provider: this.constructor.name,
|
||||
cause: userInfoResult.error,
|
||||
});
|
||||
}));
|
||||
throw new KnownErrors.OAuthProviderTemporarilyUnavailable();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -178,3 +178,9 @@ A: In `apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route
|
||||
|
||||
Q: Why shouldn't OAuth callback retries wrap the whole `getCallback` flow?
|
||||
A: The authorization code exchange (`oauthClient.callback` / `oauthCallback`) is effectively one-shot, so retrying the full callback can convert a transient downstream failure into `invalid_grant` on the next attempt. Retries should wrap only post-exchange user-info fetches (`postProcessUserInfo`) and only for transient network/timeout errors.
|
||||
|
||||
Q: How should OAuth callback behave when userinfo retries still fail?
|
||||
A: After exhausting transient-network retries in `OAuthBaseProvider.getCallback`, capture internal diagnostics (`oauth-userinfo-retry-exhausted`) but throw `KnownErrors.OAuthProviderTemporarilyUnavailable` so clients get a user-recoverable error/redirect flow instead of an internal assertion.
|
||||
|
||||
Q: How should OAuth callback errors be surfaced to handler-based clients?
|
||||
A: In `apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx`, prefer redirecting known errors to the original OAuth callback URL (`redirectUri`) with `error`, `error_description`, `errorCode`, `message`, and `details` query params (fallback to `errorRedirectUrl` if needed). In template client handling (`packages/template/src/lib/auth.ts` + `components-page/oauth-callback.tsx`), detect those params, reconstruct a `KnownError`, and route to the handler error page so users get actionable UI instead of silent sign-in redirects.
|
||||
|
||||
@ -1423,6 +1423,16 @@ const OAuthProviderAccessDenied = createKnownErrorConstructor(
|
||||
() => [] as const,
|
||||
);
|
||||
|
||||
const OAuthProviderTemporarilyUnavailable = createKnownErrorConstructor(
|
||||
KnownError,
|
||||
"OAUTH_PROVIDER_TEMPORARILY_UNAVAILABLE",
|
||||
() => [
|
||||
503,
|
||||
"The OAuth provider is temporarily unavailable. Please try signing in again.",
|
||||
] as const,
|
||||
() => [] as const,
|
||||
);
|
||||
|
||||
const ContactChannelAlreadyUsedForAuthBySomeoneElse = createKnownErrorConstructor(
|
||||
KnownError,
|
||||
"CONTACT_CHANNEL_ALREADY_USED_FOR_AUTH_BY_SOMEONE_ELSE",
|
||||
@ -1958,6 +1968,7 @@ export const KnownErrors = {
|
||||
InvalidAppleCredentials,
|
||||
TeamPermissionNotFound,
|
||||
OAuthProviderAccessDenied,
|
||||
OAuthProviderTemporarilyUnavailable,
|
||||
ContactChannelAlreadyUsedForAuthBySomeoneElse,
|
||||
InvalidPollingCodeError,
|
||||
ApiKeyNotValid,
|
||||
|
||||
@ -79,5 +79,22 @@ export function ErrorPage(props: { fullPage?: boolean, searchParams: Record<stri
|
||||
);
|
||||
}
|
||||
|
||||
if (KnownErrors.OAuthProviderTemporarilyUnavailable.isInstance(error)) {
|
||||
return (
|
||||
<MessageCard
|
||||
title={t("OAuth provider is temporarily unavailable")}
|
||||
fullPage={!!props.fullPage}
|
||||
primaryButtonText={t("Try sign-in again")}
|
||||
primaryAction={() => stackApp.redirectToSignIn()}
|
||||
secondaryButtonText={t("Go Home")}
|
||||
secondaryAction={() => stackApp.redirectToHome()}
|
||||
>
|
||||
<Typography>
|
||||
{t("The OAuth provider could not complete sign-in right now. Please try again in a moment.")}
|
||||
</Typography>
|
||||
</MessageCard>
|
||||
);
|
||||
}
|
||||
|
||||
return <KnownErrorMessageCard error={error} fullPage={!!props.fullPage} />;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { KnownError } from "@stackframe/stack-shared";
|
||||
import { captureError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { Spinner, cn } from "@stackframe/stack-ui";
|
||||
@ -7,30 +8,33 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useStackApp } from "..";
|
||||
import { MaybeFullPage } from "../components/elements/maybe-full-page";
|
||||
import { StyledLink } from "../components/link";
|
||||
import { envVars } from "../lib/env";
|
||||
import { useTranslation } from "../lib/translations";
|
||||
|
||||
export function OAuthCallback({ fullPage }: { fullPage?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const app = useStackApp();
|
||||
const called = useRef(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [showRedirectLink, setShowRedirectLink] = useState(false);
|
||||
|
||||
useEffect(() => runAsynchronously(async () => {
|
||||
if (called.current) return;
|
||||
called.current = true;
|
||||
let hasRedirected = false;
|
||||
let callbackError: unknown = null;
|
||||
try {
|
||||
hasRedirected = await app.callOAuthCallback();
|
||||
const hasRedirected = await app.callOAuthCallback();
|
||||
if (!hasRedirected) {
|
||||
await app.redirectToSignIn({ noRedirectBack: true });
|
||||
}
|
||||
} catch (e) {
|
||||
callbackError = e;
|
||||
if (KnownError.isKnownError(e)) {
|
||||
const errorUrl = new URL(app.urls.error, window.location.href);
|
||||
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());
|
||||
return;
|
||||
}
|
||||
captureError("<OAuthCallback />", e);
|
||||
setError(e);
|
||||
}
|
||||
if (!hasRedirected && (callbackError == null || envVars.NODE_ENV === "production")) {
|
||||
await app.redirectToSignIn({ noRedirectBack: true });
|
||||
window.location.replace(new URL(app.urls.error, window.location.href).toString());
|
||||
}
|
||||
}), []);
|
||||
|
||||
@ -53,11 +57,6 @@ export function OAuthCallback({ fullPage }: { fullPage?: boolean }) {
|
||||
<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}
|
||||
{error ? <div>
|
||||
<p>{t("Something went wrong while processing the OAuth callback:")}</p>
|
||||
<pre>{JSON.stringify(error, null, 2)}</pre>
|
||||
<p>{t("This is most likely an error in Stack. Please report it.")}</p>
|
||||
</div> : null}
|
||||
</div>
|
||||
</MaybeFullPage>
|
||||
);
|
||||
|
||||
@ -37,9 +37,54 @@ export async function addNewOAuthProviderOrScope(
|
||||
*
|
||||
* Must be synchronous for the logic in callOAuthCallback to work without race conditions.
|
||||
*/
|
||||
function consumeOAuthCallbackQueryParams() {
|
||||
type OAuthCallbackConsumptionResult =
|
||||
| {
|
||||
type: "oauth-response",
|
||||
originalUrl: URL,
|
||||
codeVerifier: string,
|
||||
state: string,
|
||||
}
|
||||
| {
|
||||
type: "known-error",
|
||||
error: KnownError,
|
||||
};
|
||||
|
||||
function consumeOAuthCallbackQueryParams(): OAuthCallbackConsumptionResult | null {
|
||||
const oauthErrorParams = ["error", "error_description", "errorCode", "message", "details"] as const;
|
||||
const requiredParams = ["code", "state"];
|
||||
const originalUrl = new URL(window.location.href);
|
||||
const knownErrorCode = originalUrl.searchParams.get("errorCode");
|
||||
const knownErrorMessage = originalUrl.searchParams.get("message");
|
||||
if (knownErrorCode && knownErrorMessage) {
|
||||
const details = originalUrl.searchParams.get("details");
|
||||
let detailsJson = {};
|
||||
if (details) {
|
||||
try {
|
||||
detailsJson = JSON.parse(details);
|
||||
} catch (error) {
|
||||
throw new StackAssertionError("OAuth callback returned malformed known-error details", {
|
||||
details,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newUrl = new URL(originalUrl);
|
||||
for (const param of oauthErrorParams) {
|
||||
newUrl.searchParams.delete(param);
|
||||
}
|
||||
window.history.replaceState({}, "", newUrl.toString());
|
||||
|
||||
return {
|
||||
type: "known-error",
|
||||
error: KnownError.fromJson({
|
||||
code: knownErrorCode,
|
||||
message: knownErrorMessage,
|
||||
details: detailsJson,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
for (const param of requiredParams) {
|
||||
if (!originalUrl.searchParams.has(param)) {
|
||||
console.warn(new Error(`Missing required query parameter on OAuth callback: ${param}. Maybe you opened or reloaded the oauth-callback page from your history?`));
|
||||
@ -83,6 +128,7 @@ function consumeOAuthCallbackQueryParams() {
|
||||
window.history.replaceState({}, "", newUrl.toString());
|
||||
|
||||
return {
|
||||
type: "oauth-response",
|
||||
originalUrl,
|
||||
codeVerifier: cookieResult.codeVerifier,
|
||||
state: expectedState,
|
||||
@ -98,6 +144,9 @@ export async function callOAuthCallback(
|
||||
// callOAuthCallback is called multiple times in parallel
|
||||
const consumed = consumeOAuthCallbackQueryParams();
|
||||
if (!consumed) return Result.ok(undefined);
|
||||
if (consumed.type === "known-error") {
|
||||
throw consumed.error;
|
||||
}
|
||||
|
||||
// the rest can be asynchronous (we now know that we are the
|
||||
// intended recipient of the callback, and the only instance
|
||||
|
||||
Loading…
Reference in New Issue
Block a user