better-auth
Version:
The most comprehensive authentication framework for TypeScript.
330 lines (329 loc) • 13.4 kB
JavaScript
import { isAPIError } from "../../utils/is-api-error.mjs";
import { getOrigin } from "../../utils/url.mjs";
import { originCheck } from "../../api/middlewares/origin-check.mjs";
import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
import { symmetricDecrypt, symmetricEncrypt } from "../../crypto/index.mjs";
import { setSessionCookie } from "../../cookies/index.mjs";
import { redirectOnError } from "../../oauth2/errors.mjs";
import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
import { parseGenericState } from "../../state.mjs";
import { PACKAGE_VERSION } from "../../version.mjs";
import { parseJSON } from "../../client/parser.mjs";
import { checkSkipProxy, resolveCurrentURL, stripTrailingSlash } from "./utils.mjs";
import { defu as defu$1 } from "defu";
import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
import * as z from "zod";
//#region src/plugins/oauth-proxy/index.ts
const consumeOAuthProxyState = async (ctx, state) => {
try {
await parseGenericState(ctx, state, { skipStateCookieCheck: true });
return true;
} catch (e) {
ctx.context.logger.warn("OAuth proxy state missing or invalid", e);
return false;
}
};
const oauthProxyQuerySchema = z.object({
callbackURL: z.string().meta({ description: "The URL to redirect to after the proxy" }),
profile: z.string().optional().meta({ description: "Encrypted OAuth profile data" })
});
const oauthCallbackQuerySchema = z.object({
code: z.string().optional(),
error: z.string().optional()
});
const oAuthProxy = (opts) => {
const maxAge = opts?.maxAge ?? 60;
const getEncryptionKey = (ctx) => opts?.secret ?? ctx.context.secretConfig;
return {
id: "oauth-proxy",
version: PACKAGE_VERSION,
options: opts,
endpoints: { oAuthProxy: createAuthEndpoint("/oauth-proxy-callback", {
method: "GET",
operationId: "oauthProxyCallback",
query: oauthProxyQuerySchema,
use: [originCheck((ctx) => ctx.query.callbackURL)],
metadata: { openapi: {
operationId: "oauthProxyCallback",
description: "OAuth Proxy Callback",
parameters: [{
in: "query",
name: "callbackURL",
required: true,
description: "The URL to redirect to after the proxy"
}, {
in: "query",
name: "profile",
required: false,
description: "Encrypted OAuth profile data"
}],
responses: { 302: {
description: "Redirect",
headers: { Location: {
description: "The URL to redirect to",
schema: { type: "string" }
} }
} }
} }
}, async (ctx) => {
const baseURLStr = typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : getOrigin(ctx.context.baseURL) || "";
const defaultErrorURL = ctx.context.options.onAPIError?.errorURL || `${stripTrailingSlash(baseURLStr)}/api/auth/error`;
const encryptedProfile = ctx.query.profile;
if (!encryptedProfile) {
ctx.context.logger.error("OAuth proxy callback missing profile data");
throw redirectOnError(ctx, defaultErrorURL, "missing_profile");
}
let decryptedPayload;
try {
decryptedPayload = await symmetricDecrypt({
key: getEncryptionKey(ctx),
data: encryptedProfile
});
} catch (e) {
ctx.context.logger.error("Failed to decrypt OAuth proxy profile", e);
throw redirectOnError(ctx, defaultErrorURL, "invalid_profile");
}
let payload;
try {
payload = parseJSON(decryptedPayload);
} catch (e) {
ctx.context.logger.error("Failed to parse OAuth proxy payload", e);
throw redirectOnError(ctx, defaultErrorURL, "invalid_payload");
}
if (typeof payload.timestamp !== "number" || !payload.userInfo || !payload.account || !payload.state || !payload.callbackURL) {
ctx.context.logger.error("Failed to parse OAuth proxy payload");
throw redirectOnError(ctx, defaultErrorURL, "invalid_payload");
}
const errorURL = payload.errorURL || defaultErrorURL;
const age = (Date.now() - payload.timestamp) / 1e3;
if (age > maxAge || age < -10) {
ctx.context.logger.error(`OAuth proxy payload expired or invalid (age: ${age}s, maxAge: ${maxAge}s)`);
throw redirectOnError(ctx, errorURL, "payload_expired");
}
if (!await consumeOAuthProxyState(ctx, payload.state)) throw redirectOnError(ctx, errorURL, "state_mismatch");
let result;
try {
result = await handleOAuthUserInfo(ctx, {
userInfo: payload.userInfo,
account: payload.account,
callbackURL: payload.callbackURL,
disableSignUp: payload.disableSignUp
});
} catch (e) {
if (isAPIError(e) && e.body?.code) throw redirectOnError(ctx, errorURL, e.body.code, e.body.message);
throw e;
}
if (result.error) {
ctx.context.logger.error("OAuth proxy callback error", result.error);
throw redirectOnError(ctx, errorURL, result.error.split(" ").join("_"));
}
if (!result.data) {
ctx.context.logger.error("OAuth proxy callback missing session data");
throw redirectOnError(ctx, errorURL, "user_creation_failed");
}
await setSessionCookie(ctx, result.data);
const finalURL = result.isRegister ? payload.newUserURL || payload.callbackURL : payload.callbackURL;
throw ctx.redirect(finalURL);
}) },
hooks: {
before: [{
matcher(context) {
return !!(context.path?.startsWith("/sign-in/social") || context.path?.startsWith("/sign-in/oauth2"));
},
handler: createAuthMiddleware(async (ctx) => {
if (checkSkipProxy(ctx, opts)) return;
const currentURL = resolveCurrentURL(ctx, opts);
const productionURL = opts?.productionURL;
const originalCallbackURL = ctx.body?.callbackURL || ctx.context.baseURL;
if (productionURL) {
const productionBaseURL = `${stripTrailingSlash(productionURL)}${ctx.context.options.basePath || "/api/auth"}`;
ctx.context.baseURL = productionBaseURL;
}
const newCallbackURL = `${stripTrailingSlash(currentURL.origin)}${ctx.context.options.basePath || "/api/auth"}/oauth-proxy-callback?callbackURL=${encodeURIComponent(originalCallbackURL)}`;
if (!ctx.body) return;
ctx.body.callbackURL = newCallbackURL;
})
}, {
matcher(context) {
return context.path === "/callback/:id";
},
handler: createAuthMiddleware(async (ctx) => {
const callbackParams = defu$1(ctx.query, ctx.body);
const state = callbackParams.state;
if (!state || typeof state !== "string") return;
let statePackage;
try {
statePackage = parseJSON(await symmetricDecrypt({
key: getEncryptionKey(ctx),
data: state
}));
} catch {
ctx.context.logger.debug("OAuth proxy: could not decrypt state package, falling back to regular callback");
return;
}
if (!statePackage.isOAuthProxy || !statePackage.state || !statePackage.stateCookie) {
ctx.context.logger.warn("Invalid OAuth proxy state package");
return;
}
const query = oauthCallbackQuerySchema.safeParse(callbackParams);
if (!query.success) {
ctx.context.logger.warn("Invalid OAuth callback query", query.error);
return;
}
const { code, error } = query.data;
let stateData;
try {
stateData = parseJSON(await symmetricDecrypt({
key: getEncryptionKey(ctx),
data: statePackage.stateCookie
}));
} catch (e) {
ctx.context.logger.error("Failed to decrypt OAuth proxy state cookie:", e);
return;
}
const errorURL = stateData.errorURL || ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
if (stateData.oauthState !== void 0 && stateData.oauthState !== statePackage.state) {
ctx.context.logger.error("OAuth proxy state binding mismatch");
throw redirectOnError(ctx, errorURL, "state_mismatch");
}
if (error) throw redirectOnError(ctx, errorURL, error);
if (!code) {
ctx.context.logger.warn("OAuth callback missing authorization code");
throw redirectOnError(ctx, errorURL, "no_code");
}
const providerId = ctx.params?.id;
const provider = ctx.context.socialProviders.find((p) => p.id === providerId);
if (!provider) {
ctx.context.logger.warn("OAuth provider not found", { providerId });
throw redirectOnError(ctx, errorURL, "oauth_provider_not_found");
}
let tokens;
try {
tokens = await provider.validateAuthorizationCode({
code,
codeVerifier: stateData.codeVerifier,
redirectURI: `${ctx.context.baseURL}/callback/${provider.id}`
});
} catch (e) {
ctx.context.logger.error("Failed to validate authorization code", e);
throw redirectOnError(ctx, errorURL, "invalid_code");
}
if (!tokens) throw redirectOnError(ctx, errorURL, "invalid_code");
const userInfo = (await provider.getUserInfo(tokens))?.user;
if (!userInfo) {
ctx.context.logger.error("Unable to get user info from provider");
throw redirectOnError(ctx, errorURL, "unable_to_get_user_info");
}
if (!userInfo.email) {
ctx.context.logger.error("Provider did not return email");
throw redirectOnError(ctx, errorURL, "email_not_found");
}
const proxyCallbackURL = new URL(stateData.callbackURL);
const finalCallbackURL = proxyCallbackURL.searchParams.get("callbackURL") || stateData.callbackURL;
const payload = {
userInfo: {
id: String(userInfo.id),
email: userInfo.email,
name: userInfo.name || "",
image: userInfo.image,
emailVerified: userInfo.emailVerified
},
account: {
providerId: provider.id,
accountId: String(userInfo.id),
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
idToken: tokens.idToken,
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
scope: tokens.scopes?.join(",")
},
state: statePackage.state,
callbackURL: finalCallbackURL,
newUserURL: stateData.newUserURL,
errorURL: stateData.errorURL,
disableSignUp: provider.disableImplicitSignUp && !stateData.requestSignUp || provider.options?.disableSignUp,
timestamp: Date.now()
};
const encryptedPayload = await symmetricEncrypt({
key: getEncryptionKey(ctx),
data: JSON.stringify(payload)
});
proxyCallbackURL.searchParams.set("profile", encryptedPayload);
throw ctx.redirect(proxyCallbackURL.toString());
})
}],
after: [{
matcher(context) {
return !!(context.path?.startsWith("/sign-in/social") || context.path?.startsWith("/sign-in/oauth2"));
},
handler: createAuthMiddleware(async (ctx) => {
if (checkSkipProxy(ctx, opts)) return;
const signInResponse = ctx.context.returned;
if (!signInResponse || typeof signInResponse !== "object" || !("url" in signInResponse)) return;
const { url: providerURL } = signInResponse;
if (typeof providerURL !== "string") return;
const oauthURL = new URL(providerURL);
const originalState = oauthURL.searchParams.get("state");
if (!originalState) return;
try {
let plaintextState;
if (ctx.context.oauthConfig.storeStateStrategy === "cookie") {
const setCookieHeader = ctx.context.responseHeaders?.get("set-cookie");
if (setCookieHeader) {
const oauthStateCookie = ctx.context.createAuthCookie("oauth_state");
const encryptedCookieValue = parseSetCookieHeader(setCookieHeader).get(oauthStateCookie.name)?.value;
if (encryptedCookieValue) plaintextState = await symmetricDecrypt({
key: ctx.context.secretConfig,
data: encryptedCookieValue
});
}
} else plaintextState = (await ctx.context.internalAdapter.findVerificationValue(originalState))?.value;
if (!plaintextState) {
ctx.context.logger.warn("No OAuth state found for proxy");
return;
}
const statePackage = {
state: originalState,
stateCookie: await symmetricEncrypt({
key: getEncryptionKey(ctx),
data: plaintextState
}),
isOAuthProxy: true
};
const encryptedPackage = await symmetricEncrypt({
key: getEncryptionKey(ctx),
data: JSON.stringify(statePackage)
});
oauthURL.searchParams.set("state", encryptedPackage);
ctx.context.returned = {
...signInResponse,
url: oauthURL.toString()
};
} catch (e) {
ctx.context.logger.error("Failed to prepare OAuth proxy state:", e);
}
})
}, {
matcher(context) {
return context.path === "/callback/:id";
},
handler: createAuthMiddleware(async (ctx) => {
const location = ctx.context.responseHeaders?.get("location");
if (!location?.includes("/oauth-proxy-callback?callbackURL") || !location.startsWith("http")) return;
const productionOrigin = getOrigin(opts?.productionURL || (typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : void 0) || ctx.context.baseURL);
const locationURL = new URL(location);
if (locationURL.origin === productionOrigin) {
const newLocation = locationURL.searchParams.get("callbackURL");
if (!newLocation) return;
ctx.setHeader("location", newLocation);
return;
}
ctx.context.logger.warn("OAuth proxy: cross-origin callback reached after hook unexpectedly");
})
}]
}
};
};
//#endregion
export { oAuthProxy };