better-auth
Version:
The most comprehensive authentication framework for TypeScript.
276 lines (275 loc) • 11.9 kB
JavaScript
import { PACKAGE_VERSION } from "../../version.mjs";
import { GENERIC_OAUTH_ERROR_CODES } from "./error-codes.mjs";
import { auth0 } from "./providers/auth0.mjs";
import { gumroad } from "./providers/gumroad.mjs";
import { hubspot } from "./providers/hubspot.mjs";
import { keycloak } from "./providers/keycloak.mjs";
import { line } from "./providers/line.mjs";
import { microsoftEntraId } from "./providers/microsoft-entra-id.mjs";
import { okta } from "./providers/okta.mjs";
import { patreon } from "./providers/patreon.mjs";
import { slack } from "./providers/slack.mjs";
import { yandex } from "./providers/yandex.mjs";
import { APIError } from "@better-auth/core/error";
import { applyDefaultAccessTokenExpiry, createAuthorizationURL, refreshAccessToken, validateAuthorizationCode, verifyProviderIdToken } from "@better-auth/core/oauth2";
import { createRemoteJWKSet, decodeJwt } from "jose";
import { betterFetch } from "@better-fetch/fetch";
//#region src/plugins/generic-oauth/index.ts
function isSecretlessTokenEndpointAuth(tokenEndpointAuth) {
return tokenEndpointAuth?.method === "private_key_jwt" || tokenEndpointAuth?.method === "none";
}
function isClientSecretTokenEndpointAuth(tokenEndpointAuth) {
return tokenEndpointAuth?.method === "client_secret_basic" || tokenEndpointAuth?.method === "client_secret_post";
}
async function fetchDiscovery(url, headers) {
const result = await betterFetch(url, {
method: "GET",
headers
});
if (result.error || !result.data) return null;
if (result.data.issuer) try {
new URL(result.data.issuer);
} catch {
return null;
}
return result.data;
}
async function fetchUserInfo(tokens, userInfoUrl) {
if (tokens.idToken) try {
const decoded = decodeJwt(tokens.idToken);
if (decoded?.sub && decoded?.email) return {
id: decoded.sub,
emailVerified: decoded.email_verified,
image: decoded.picture,
...decoded
};
} catch {}
if (!userInfoUrl) return null;
const userInfo = await betterFetch(userInfoUrl, {
method: "GET",
headers: { Authorization: `Bearer ${tokens.accessToken}` }
});
if (userInfo.error || !userInfo.data) return null;
return {
...userInfo.data,
email: userInfo.data.email,
emailVerified: userInfo.data.email_verified ?? false,
image: userInfo.data.picture,
name: userInfo.data.name
};
}
/**
* A generic OAuth plugin that registers any OAuth/OIDC provider
* as a first-class social provider.
*
* Providers are used through the standard `signIn.social` and
* `callback/:id` core endpoints — no plugin-specific endpoints needed.
*/
const genericOAuth = (options) => {
const seenIds = /* @__PURE__ */ new Set();
const nonUniqueIds = /* @__PURE__ */ new Set();
for (const config of options.config) {
const id = config.providerId;
if (seenIds.has(id)) nonUniqueIds.add(id);
seenIds.add(id);
}
if (nonUniqueIds.size > 0) console.warn(`Duplicate provider IDs found: ${Array.from(nonUniqueIds).join(", ")}`);
return {
id: "generic-oauth",
version: PACKAGE_VERSION,
init: async (ctx) => {
const genericProviders = [];
for (const c of options.config) {
let authorizationUrl = c.authorizationUrl;
let tokenUrl = c.tokenUrl;
let userInfoUrl = c.userInfoUrl;
let endSessionEndpoint = c.endSessionEndpoint;
let issuer;
let isOidc = false;
let idTokenConfig;
if (c.discoveryUrl) {
const discovered = await fetchDiscovery(c.discoveryUrl, c.discoveryHeaders).catch((err) => {
ctx.logger.error(`Discovery fetch failed for "${c.providerId}": ${err}`);
return null;
});
if (discovered) {
if (!discovered.issuer && !c.accountIssuer) throw new Error(`Provider "${c.providerId}": discovery did not return an issuer. Configure accountIssuer explicitly to establish a stable account namespace.`);
authorizationUrl ??= discovered.authorization_endpoint;
tokenUrl ??= discovered.token_endpoint;
userInfoUrl ??= discovered.userinfo_endpoint;
endSessionEndpoint ??= discovered.end_session_endpoint;
issuer = discovered.issuer;
const signingAlgs = discovered.id_token_signing_alg_values_supported;
isOidc = Array.isArray(signingAlgs) && signingAlgs.length > 0;
if (discovered.jwks_uri && discovered.issuer) {
let jwksUrl;
try {
jwksUrl = new URL(discovered.jwks_uri, c.discoveryUrl);
} catch {
throw new Error(`Provider "${c.providerId}": invalid jwks_uri "${discovered.jwks_uri}" in discovery document.`);
}
idTokenConfig = {
jwks: createRemoteJWKSet(jwksUrl),
issuer: discovered.issuer,
audience: c.clientId,
algorithms: isOidc ? signingAlgs : void 0
};
}
} else if (!c.accountIssuer) throw new Error(`Provider "${c.providerId}": discovery returned no valid data. Provider initialization stopped to keep its account issuer stable.`);
else if (!authorizationUrl || !tokenUrl) ctx.logger.error(`Provider "${c.providerId}": discovery returned no data and no explicit endpoints configured. OAuth sign-in will fail for this provider.`);
}
if (c.requireIdTokenVerification && !idTokenConfig) throw new Error(`Provider "${c.providerId}": requires verified ID tokens, but discovery did not provide a usable issuer and jwks_uri.`);
const tokenEndpointAuth = c.tokenEndpointAuth;
if (c.clientSecret && isSecretlessTokenEndpointAuth(tokenEndpointAuth)) throw new Error(`Provider "${c.providerId}": tokenEndpointAuth.method "${tokenEndpointAuth?.method}" cannot be combined with clientSecret`);
if (!c.clientSecret && isClientSecretTokenEndpointAuth(tokenEndpointAuth)) throw new Error(`Provider "${c.providerId}": tokenEndpointAuth.method "${tokenEndpointAuth?.method}" requires clientSecret`);
if (!c.clientSecret && !tokenEndpointAuth && c.authentication === "basic") throw new Error(`Provider "${c.providerId}": authentication "basic" requires clientSecret`);
const accountSubject = c.accountSubject;
const accountIssuer = c.accountIssuer;
const provider = {
id: c.providerId,
name: c.name ?? c.providerId,
issuer,
accountSubject: ({ tokens, profile }) => {
const genericProfile = profile;
if (accountSubject) return accountSubject({
tokens,
profile: genericProfile
});
return isOidc ? genericProfile.sub ?? "" : genericProfile.id ?? "";
},
accountIssuer: typeof accountIssuer === "function" ? ({ tokens, profile }) => accountIssuer({
tokens,
profile
}) : accountIssuer ?? issuer,
idToken: idTokenConfig,
requiresIdTokenNonce: idTokenConfig !== void 0 && c.disableIdTokenNonceBinding !== true,
allowIdpInitiated: c.allowIdpInitiated,
async createEndSessionURL(data) {
if (c.disableProviderLogout) return null;
if (!endSessionEndpoint) return null;
let url;
try {
url = new URL(endSessionEndpoint);
} catch {
return null;
}
if (data.idToken) url.searchParams.set("id_token_hint", data.idToken);
const configuredRedirectURI = data.postLogoutRedirectURI ?? c.postLogoutRedirectURI;
const postLogoutRedirectURI = configuredRedirectURI ? new URL(configuredRedirectURI, ctx.baseURL).toString() : void 0;
if (postLogoutRedirectURI) {
url.searchParams.set("post_logout_redirect_uri", postLogoutRedirectURI);
url.searchParams.set("client_id", c.clientId);
if (data.state) url.searchParams.set("state", data.state);
} else if (!data.idToken) url.searchParams.set("client_id", c.clientId);
return url;
},
createAuthorizationURL(data) {
if (!authorizationUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.INVALID_OAUTH_CONFIGURATION);
return createAuthorizationURL({
id: c.providerId,
options: {
clientId: c.clientId,
clientSecret: c.clientSecret,
redirectURI: c.redirectURI
},
authorizationEndpoint: authorizationUrl,
state: data.state,
codeVerifier: c.pkce ?? true ? data.codeVerifier : void 0,
scopes: (() => {
const merged = [...data.scopes ?? [], ...c.scopes ?? []];
if (isOidc && !merged.includes("openid")) merged.unshift("openid");
return merged;
})(),
redirectURI: data.redirectURI,
prompt: c.prompt,
accessType: c.accessType,
responseType: c.responseType,
responseMode: c.responseMode,
nonce: data.idTokenNonce,
additionalParams: {
...c.authorizationUrlParams ?? {},
...data.additionalParams ?? {}
},
loginHint: data.loginHint
});
},
async validateAuthorizationCode(data) {
if (c.getToken) return applyDefaultAccessTokenExpiry(await c.getToken(data), c.accessTokenExpiresIn);
if (!tokenUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.TOKEN_URL_NOT_FOUND);
return applyDefaultAccessTokenExpiry(await validateAuthorizationCode({
headers: c.authorizationHeaders,
code: data.code,
codeVerifier: c.pkce ?? true ? data.codeVerifier : void 0,
redirectURI: data.redirectURI,
options: {
clientId: c.clientId,
clientSecret: c.clientSecret,
redirectURI: c.redirectURI
},
tokenEndpoint: tokenUrl,
authentication: c.authentication,
tokenEndpointAuth,
additionalParams: c.tokenUrlParams
}), c.accessTokenExpiresIn);
},
async getUserInfo(tokens) {
const { expectedIdTokenNonce, ...oauthTokens } = tokens;
if (oauthTokens.idToken && provider.idToken) {
if (!await verifyProviderIdToken(provider, oauthTokens.idToken, expectedIdTokenNonce)) {
ctx.logger.error(`Provider "${c.providerId}": id_token failed verification against the discovery JWKS or expected nonce`);
return null;
}
}
const raw = c.getUserInfo ? await c.getUserInfo(oauthTokens) : await fetchUserInfo(oauthTokens, userInfoUrl);
if (!raw) return null;
const mapped = c.mapProfileToUser ? await c.mapProfileToUser(raw) : {};
const user = {
email: raw.email,
emailVerified: raw.emailVerified,
image: raw.image,
name: raw.name,
...mapped
};
return {
user: {
...user,
image: user.image ?? void 0
},
data: raw
};
},
async refreshAccessToken(refreshToken, refreshCtx) {
if (!tokenUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.TOKEN_URL_NOT_FOUND);
const resolvedRefreshParams = typeof c.refreshTokenParams === "function" ? await c.refreshTokenParams(refreshCtx) : c.refreshTokenParams;
return applyDefaultAccessTokenExpiry(await refreshAccessToken({
refreshToken,
options: {
clientId: c.clientId,
clientSecret: c.clientSecret
},
authentication: c.authentication,
tokenEndpointAuth,
tokenEndpoint: tokenUrl,
extraParams: resolvedRefreshParams
}), c.accessTokenExpiresIn);
},
disableImplicitSignUp: c.disableImplicitSignUp,
disableSignUp: c.disableSignUp,
options: {
disableSignUp: c.disableSignUp,
overrideUserInfoOnSignIn: c.overrideUserInfo,
requireEmailVerification: c.requireEmailVerification
}
};
genericProviders.push(provider);
}
const existingIds = new Set(ctx.socialProviders.map((p) => p.id));
for (const gp of genericProviders) if (existingIds.has(gp.id)) ctx.logger.warn(`Generic OAuth provider "${gp.id}" shadows a built-in social provider with the same ID`);
return { context: { socialProviders: genericProviders.concat(ctx.socialProviders) } };
},
options,
$ERROR_CODES: GENERIC_OAUTH_ERROR_CODES
};
};
//#endregion
export { auth0, genericOAuth, gumroad, hubspot, keycloak, line, microsoftEntraId, okta, patreon, slack, yandex };