UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

395 lines (393 loc) • 16 kB
import { generateState, parseState } from "../../oauth2/state.mjs"; import { setTokenUtil } from "../../oauth2/utils.mjs"; import { setSessionCookie } from "../../cookies/index.mjs"; import { sessionMiddleware } from "../../api/routes/session.mjs"; import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs"; import { HIDE_METADATA } from "../../utils/hide-metadata.mjs"; import "../../utils/index.mjs"; import "../../api/index.mjs"; import { GENERIC_OAUTH_ERROR_CODES } from "./error-codes.mjs"; import { BASE_ERROR_CODES } from "@better-auth/core/error"; import { createAuthorizationURL, validateAuthorizationCode } from "@better-auth/core/oauth2"; import * as z from "zod"; import { APIError } from "better-call"; import { createAuthEndpoint } from "@better-auth/core/api"; import { decodeJwt } from "jose"; import { betterFetch } from "@better-fetch/fetch"; //#region src/plugins/generic-oauth/routes.ts const signInWithOAuth2BodySchema = z.object({ providerId: z.string().meta({ description: "The provider ID for the OAuth provider" }), callbackURL: z.string().meta({ description: "The URL to redirect to after sign in" }).optional(), errorCallbackURL: z.string().meta({ description: "The URL to redirect to if an error occurs" }).optional(), newUserCallbackURL: z.string().meta({ description: "The URL to redirect to after login if the user is new. Eg: \"/welcome\"" }).optional(), disableRedirect: z.boolean().meta({ description: "Disable redirect" }).optional(), scopes: z.array(z.string()).meta({ description: "Scopes to be passed to the provider authorization request." }).optional(), requestSignUp: z.boolean().meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider. Eg: false" }).optional(), additionalData: z.record(z.string(), z.any()).optional() }); /** * ### Endpoint * * POST `/sign-in/oauth2` * * ### API Methods * * **server:** * `auth.api.signInWithOAuth2` * * **client:** * `authClient.signIn.oauth2` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/sign-in#api-method-sign-in-oauth2) */ const signInWithOAuth2 = (options) => createAuthEndpoint("/sign-in/oauth2", { method: "POST", body: signInWithOAuth2BodySchema, metadata: { openapi: { description: "Sign in with OAuth2", responses: { 200: { description: "Sign in with OAuth2", content: { "application/json": { schema: { type: "object", properties: { url: { type: "string" }, redirect: { type: "boolean" } } } } } } } } } }, async (ctx) => { const { providerId } = ctx.body; const config = options.config.find((c) => c.providerId === providerId); if (!config) throw new APIError("BAD_REQUEST", { message: `${GENERIC_OAUTH_ERROR_CODES.PROVIDER_CONFIG_NOT_FOUND} ${providerId}` }); const { discoveryUrl, authorizationUrl, tokenUrl, clientId, clientSecret, scopes, redirectURI, responseType, pkce, prompt, accessType, authorizationUrlParams, responseMode } = config; let finalAuthUrl = authorizationUrl; let finalTokenUrl = tokenUrl; if (discoveryUrl) { const discovery = await betterFetch(discoveryUrl, { method: "GET", headers: config.discoveryHeaders, onError(context) { ctx.context.logger.error(context.error.message, context.error, { discoveryUrl }); } }); if (discovery.data) { finalAuthUrl = discovery.data.authorization_endpoint; finalTokenUrl = discovery.data.token_endpoint; } } if (!finalAuthUrl || !finalTokenUrl) throw new APIError("BAD_REQUEST", { message: GENERIC_OAUTH_ERROR_CODES.INVALID_OAUTH_CONFIGURATION }); if (authorizationUrlParams) { const withAdditionalParams = new URL(finalAuthUrl); for (const [paramName, paramValue] of Object.entries(authorizationUrlParams)) withAdditionalParams.searchParams.set(paramName, paramValue); finalAuthUrl = withAdditionalParams.toString(); } const additionalParams = typeof authorizationUrlParams === "function" ? authorizationUrlParams(ctx) : authorizationUrlParams; const { state, codeVerifier } = await generateState(ctx, void 0, ctx.body.additionalData); const authUrl = await createAuthorizationURL({ id: providerId, options: { clientId, clientSecret, redirectURI }, authorizationEndpoint: finalAuthUrl, state, codeVerifier: pkce ? codeVerifier : void 0, scopes: ctx.body.scopes ? [...ctx.body.scopes, ...scopes || []] : scopes || [], redirectURI: `${ctx.context.baseURL}/oauth2/callback/${providerId}`, prompt, accessType, responseType, responseMode, additionalParams }); return ctx.json({ url: authUrl.toString(), redirect: !ctx.body.disableRedirect }); }); const OAuth2CallbackQuerySchema = z.object({ code: z.string().meta({ description: "The OAuth2 code" }).optional(), error: z.string().meta({ description: "The error message, if any" }).optional(), error_description: z.string().meta({ description: "The error description, if any" }).optional(), state: z.string().meta({ description: "The state parameter from the OAuth2 request" }).optional() }); const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:providerId", { method: "GET", query: OAuth2CallbackQuerySchema, metadata: { ...HIDE_METADATA, allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"], openapi: { description: "OAuth2 callback", responses: { 200: { description: "OAuth2 callback", content: { "application/json": { schema: { type: "object", properties: { url: { type: "string" } } } } } } } } } }, async (ctx) => { const defaultErrorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`; if (ctx.query.error || !ctx.query.code) throw ctx.redirect(`${defaultErrorURL}?error=${ctx.query.error || "oAuth_code_missing"}&error_description=${ctx.query.error_description}`); const providerId = ctx.params?.providerId; if (!providerId) throw new APIError("BAD_REQUEST", { message: GENERIC_OAUTH_ERROR_CODES.PROVIDER_ID_REQUIRED }); const providerConfig = options.config.find((p) => p.providerId === providerId); if (!providerConfig) throw new APIError("BAD_REQUEST", { message: `${GENERIC_OAUTH_ERROR_CODES.PROVIDER_CONFIG_NOT_FOUND} ${providerId}` }); let tokens = void 0; const { callbackURL, codeVerifier, errorURL, requestSignUp, newUserURL, link } = await parseState(ctx); const code = ctx.query.code; function redirectOnError(error) { const defaultErrorURL$1 = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`; let url = errorURL || defaultErrorURL$1; if (url.includes("?")) url = `${url}&error=${error}`; else url = `${url}?error=${error}`; throw ctx.redirect(url); } let finalTokenUrl = providerConfig.tokenUrl; let finalUserInfoUrl = providerConfig.userInfoUrl; if (providerConfig.discoveryUrl) { const discovery = await betterFetch(providerConfig.discoveryUrl, { method: "GET", headers: providerConfig.discoveryHeaders }); if (discovery.data) { finalTokenUrl = discovery.data.token_endpoint; finalUserInfoUrl = discovery.data.userinfo_endpoint; } } try { if (providerConfig.getToken) tokens = await providerConfig.getToken({ code, redirectURI: `${ctx.context.baseURL}/oauth2/callback/${providerConfig.providerId}`, codeVerifier: providerConfig.pkce ? codeVerifier : void 0 }); else { if (!finalTokenUrl) throw new APIError("BAD_REQUEST", { message: GENERIC_OAUTH_ERROR_CODES.INVALID_OAUTH_CONFIG }); const additionalParams = typeof providerConfig.tokenUrlParams === "function" ? providerConfig.tokenUrlParams(ctx) : providerConfig.tokenUrlParams; tokens = await validateAuthorizationCode({ headers: providerConfig.authorizationHeaders, code, codeVerifier: providerConfig.pkce ? codeVerifier : void 0, redirectURI: `${ctx.context.baseURL}/oauth2/callback/${providerConfig.providerId}`, options: { clientId: providerConfig.clientId, clientSecret: providerConfig.clientSecret, redirectURI: providerConfig.redirectURI }, tokenEndpoint: finalTokenUrl, authentication: providerConfig.authentication, additionalParams }); } } catch (e) { ctx.context.logger.error(e && typeof e === "object" && "name" in e ? e.name : "", e); throw redirectOnError("oauth_code_verification_failed"); } if (!tokens) throw new APIError("BAD_REQUEST", { message: GENERIC_OAUTH_ERROR_CODES.INVALID_OAUTH_CONFIG }); const userInfo = await (async function handleUserInfo() { const userInfo$1 = providerConfig.getUserInfo ? await providerConfig.getUserInfo(tokens) : await getUserInfo(tokens, finalUserInfoUrl); if (!userInfo$1) throw redirectOnError("user_info_is_missing"); const mapUser = providerConfig.mapProfileToUser ? await providerConfig.mapProfileToUser(userInfo$1) : userInfo$1; const email = mapUser.email ? mapUser.email.toLowerCase() : userInfo$1.email?.toLowerCase(); if (!email) { ctx.context.logger.error("Unable to get user info", userInfo$1); throw redirectOnError("email_is_missing"); } const id = mapUser.id ? String(mapUser.id) : String(userInfo$1.id); const name = mapUser.name ? mapUser.name : userInfo$1.name; if (!name) { ctx.context.logger.error("Unable to get user info", userInfo$1); throw redirectOnError("name_is_missing"); } return { ...userInfo$1, ...mapUser, email, id, name }; })(); if (link) { if (ctx.context.options.account?.accountLinking?.allowDifferentEmails !== true && link.email !== userInfo.email) return redirectOnError("email_doesn't_match"); const existingAccount = await ctx.context.internalAdapter.findAccountByProviderId(String(userInfo.id), providerConfig.providerId); if (existingAccount) { if (existingAccount.userId !== link.userId) return redirectOnError("account_already_linked_to_different_user"); const updateData = Object.fromEntries(Object.entries({ accessToken: await setTokenUtil(tokens.accessToken, ctx.context), idToken: tokens.idToken, refreshToken: await setTokenUtil(tokens.refreshToken, ctx.context), accessTokenExpiresAt: tokens.accessTokenExpiresAt, refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, scope: tokens.scopes?.join(",") }).filter(([_, value]) => value !== void 0)); await ctx.context.internalAdapter.updateAccount(existingAccount.id, updateData); } else if (!await ctx.context.internalAdapter.createAccount({ userId: link.userId, providerId: providerConfig.providerId, accountId: userInfo.id, accessToken: await setTokenUtil(tokens.accessToken, ctx.context), accessTokenExpiresAt: tokens.accessTokenExpiresAt, refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, scope: tokens.scopes?.join(","), refreshToken: await setTokenUtil(tokens.refreshToken, ctx.context), idToken: tokens.idToken })) return redirectOnError("unable_to_link_account"); let toRedirectTo$1; try { toRedirectTo$1 = callbackURL.toString(); } catch { toRedirectTo$1 = callbackURL; } throw ctx.redirect(toRedirectTo$1); } const result = await handleOAuthUserInfo(ctx, { userInfo, account: { providerId: providerConfig.providerId, accountId: userInfo.id, ...tokens, scope: tokens.scopes?.join(",") }, callbackURL, disableSignUp: providerConfig.disableImplicitSignUp && !requestSignUp || providerConfig.disableSignUp, overrideUserInfo: providerConfig.overrideUserInfo }); if (result.error) return redirectOnError(result.error.split(" ").join("_")); const { session, user } = result.data; await setSessionCookie(ctx, { session, user }); let toRedirectTo; try { toRedirectTo = (result.isRegister ? newUserURL || callbackURL : callbackURL).toString(); } catch { toRedirectTo = result.isRegister ? newUserURL || callbackURL : callbackURL; } throw ctx.redirect(toRedirectTo); }); const OAuth2LinkAccountBodySchema = z.object({ providerId: z.string(), callbackURL: z.string(), scopes: z.array(z.string()).meta({ description: "Additional scopes to request when linking the account" }).optional(), errorCallbackURL: z.string().meta({ description: "The URL to redirect to if there is an error during the link process" }).optional() }); /** * ### Endpoint * * POST `/oauth2/link` * * ### API Methods * * **server:** * `auth.api.oAuth2LinkAccount` * * **client:** * `authClient.oauth2.link` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/generic-oauth#api-method-oauth2-link) */ const oAuth2LinkAccount = (options) => createAuthEndpoint("/oauth2/link", { method: "POST", body: OAuth2LinkAccountBodySchema, use: [sessionMiddleware], metadata: { openapi: { description: "Link an OAuth2 account to the current user session", responses: { "200": { description: "Authorization URL generated successfully for linking an OAuth2 account", content: { "application/json": { schema: { type: "object", properties: { url: { type: "string", format: "uri", description: "The authorization URL to redirect the user to for linking the OAuth2 account" }, redirect: { type: "boolean", description: "Indicates that the client should redirect to the provided URL", enum: [true] } }, required: ["url", "redirect"] } } } } } } } }, async (c) => { const session = c.context.session; if (!session) throw new APIError("UNAUTHORIZED", { message: GENERIC_OAUTH_ERROR_CODES.SESSION_REQUIRED }); const provider = options.config.find((p) => p.providerId === c.body.providerId); if (!provider) throw new APIError("NOT_FOUND", { message: BASE_ERROR_CODES.PROVIDER_NOT_FOUND }); const { providerId, clientId, clientSecret, redirectURI, authorizationUrl, discoveryUrl, pkce, scopes, prompt, accessType, authorizationUrlParams } = provider; let finalAuthUrl = authorizationUrl; if (!finalAuthUrl) { if (!discoveryUrl) throw new APIError("BAD_REQUEST", { message: GENERIC_OAUTH_ERROR_CODES.INVALID_OAUTH_CONFIGURATION }); const discovery = await betterFetch(discoveryUrl, { method: "GET", headers: provider.discoveryHeaders, onError(context) { c.context.logger.error(context.error.message, context.error, { discoveryUrl }); } }); if (discovery.data) finalAuthUrl = discovery.data.authorization_endpoint; } if (!finalAuthUrl) throw new APIError("BAD_REQUEST", { message: GENERIC_OAUTH_ERROR_CODES.INVALID_OAUTH_CONFIGURATION }); const state = await generateState(c, { userId: session.user.id, email: session.user.email }, void 0); const additionalParams = typeof authorizationUrlParams === "function" ? authorizationUrlParams(c) : authorizationUrlParams; const url = await createAuthorizationURL({ id: providerId, options: { clientId, clientSecret, redirectURI: redirectURI || `${c.context.baseURL}/oauth2/callback/${providerId}` }, authorizationEndpoint: finalAuthUrl, state: state.state, codeVerifier: pkce ? state.codeVerifier : void 0, scopes: c.body.scopes || scopes || [], redirectURI: redirectURI || `${c.context.baseURL}/oauth2/callback/${providerId}`, prompt, accessType, additionalParams }); return c.json({ url: url.toString(), redirect: true }); }); async function getUserInfo(tokens, finalUserInfoUrl) { if (tokens.idToken) { const decoded = decodeJwt(tokens.idToken); if (decoded) { if (decoded.sub && decoded.email) return { id: decoded.sub, emailVerified: decoded.email_verified, image: decoded.picture, ...decoded }; } } if (!finalUserInfoUrl) return null; const userInfo = await betterFetch(finalUserInfoUrl, { method: "GET", headers: { Authorization: `Bearer ${tokens.accessToken}` } }); return { id: userInfo.data?.sub ?? "", emailVerified: userInfo.data?.email_verified ?? false, email: userInfo.data?.email, image: userInfo.data?.picture, name: userInfo.data?.name, ...userInfo.data }; } //#endregion export { getUserInfo, oAuth2Callback, oAuth2LinkAccount, signInWithOAuth2 }; //# sourceMappingURL=routes.mjs.map