stack/packages/template/src/lib/auth.ts
2025-08-24 11:36:01 -07:00

145 lines
5.3 KiB
TypeScript

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 signInWithOAuth(
iface: StackClientInterface,
options: {
provider: string,
redirectUrl: string,
errorRedirectUrl: string,
providerScope?: string,
},
session: InternalSession,
) {
const { codeChallenge, state } = await saveVerifierAndState();
const location = await iface.getOAuthUrl({
provider: options.provider,
redirectUrl: constructRedirectUrl(options.redirectUrl, "redirectUrl"),
errorRedirectUrl: constructRedirectUrl(options.errorRedirectUrl, "errorRedirectUrl"),
codeChallenge,
state,
type: "authenticate",
providerScope: options.providerScope,
session,
});
window.location.assign(location);
await neverResolve();
}
export async function addNewOAuthProviderOrScope(
iface: StackClientInterface,
options: {
provider: string,
redirectUrl: string,
errorRedirectUrl: string,
providerScope?: string,
},
session: InternalSession,
) {
const { codeChallenge, state } = await saveVerifierAndState();
const location = await iface.getOAuthUrl({
provider: options.provider,
redirectUrl: constructRedirectUrl(options.redirectUrl, "redirectUrl"),
errorRedirectUrl: constructRedirectUrl(options.errorRedirectUrl, "errorRedirectUrl"),
afterCallbackRedirectUrl: constructRedirectUrl(window.location.href, "afterCallbackRedirectUrl"),
codeChallenge,
state,
type: "link",
session,
providerScope: options.providerScope,
});
window.location.assign(location);
await neverResolve();
}
/**
* Checks if the current URL has the query parameters for an OAuth callback, and if so, removes them.
*
* Must be synchronous for the logic in callOAuthCallback to work without race conditions.
*/
function consumeOAuthCallbackQueryParams() {
const requiredParams = ["code", "state"];
const originalUrl = new URL(window.location.href);
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?`));
return null;
}
}
const expectedState = originalUrl.searchParams.get("state") ?? throwErr("This should never happen; isn't state required above?");
const cookieResult = consumeVerifierAndStateCookie(expectedState);
if (!cookieResult) {
// If the state can't be found in the cookies, then the callback wasn't meant for us.
// Maybe the website uses another OAuth library?
console.warn(deindent`
Stack found an outer OAuth callback state in the query parameters, but not in cookies.
This could have multiple reasons:
- The cookie expired, because the OAuth flow took too long.
- The user's browser deleted the cookie, either manually or because of a very strict cookie policy.
- The cookie was already consumed by this page, and the user already logged in.
- You are using another OAuth client library with the same callback URL as Stack.
- The user opened the OAuth callback page from their history.
Either way, it is probably safe to ignore this warning unless you are debugging an OAuth issue.
`);
return null;
}
const newUrl = new URL(originalUrl);
for (const param of requiredParams) {
newUrl.searchParams.delete(param);
}
// let's get rid of the authorization code in the history as we
// don't redirect to `redirectUrl` if there's a validation error
// (as the redirectUrl might be malicious!).
//
// We use history.replaceState instead of location.assign(...) to
// prevent an unnecessary reload
window.history.replaceState({}, "", newUrl.toString());
return {
originalUrl,
codeVerifier: cookieResult.codeVerifier,
state: expectedState,
};
}
export async function callOAuthCallback(
iface: StackClientInterface,
redirectUrl: string,
) {
// note: this part of the function (until the return) needs
// to be synchronous, to prevent race conditions when
// callOAuthCallback is called multiple times in parallel
const consumed = consumeOAuthCallbackQueryParams();
if (!consumed) return Result.ok(undefined);
// the rest can be asynchronous (we now know that we are the
// intended recipient of the callback, and the only instance
// of callOAuthCallback that's running)
try {
return Result.ok(await iface.callOAuthCallback({
oauthParams: consumed.originalUrl.searchParams,
redirectUri: constructRedirectUrl(redirectUrl, "redirectUri"),
codeVerifier: consumed.codeVerifier,
state: consumed.state,
}));
} catch (e) {
if (KnownError.isKnownError(e)) {
throw e;
}
throw new StackAssertionError("Error signing in during OAuth callback. Please try again.", { cause: e });
}
}