UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

564 lines (563 loc) • 22.4 kB
import { shouldBindAccountCookieToSessionUser } from "../../context/store-capabilities.mjs"; import { parseAccountOutput } from "../../db/schema.mjs"; import { getAccountCookie, setAccountCookie } from "../../cookies/session-store.mjs"; import { getAwaitableValue } from "../../context/helpers.mjs"; import { missingEmailLogMessage } from "../../oauth2/errors.mjs"; import { decryptOAuthToken, setTokenUtil } from "../../oauth2/utils.mjs"; import { applyUpdateUserInfoOnLink } from "../../oauth2/link-account.mjs"; import { generateState } from "../../oauth2/state.mjs"; import { freshSessionMiddleware, getSessionFromCtx, isStateful, sessionMiddleware } from "./session.mjs"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import { SocialProviderListEnum } from "@better-auth/core/social-providers"; import { createAuthEndpoint } from "@better-auth/core/api"; import * as z from "zod"; //#region src/api/routes/account.ts const listUserAccounts = createAuthEndpoint("/list-accounts", { method: "GET", use: [sessionMiddleware], metadata: { openapi: { operationId: "listUserAccounts", description: "List all accounts linked to the user", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "array", items: { type: "object", properties: { id: { type: "string" }, providerId: { type: "string" }, createdAt: { type: "string", format: "date-time" }, updatedAt: { type: "string", format: "date-time" }, accountId: { type: "string" }, userId: { type: "string" }, scopes: { type: "array", items: { type: "string" } } }, required: [ "id", "providerId", "createdAt", "updatedAt", "accountId", "userId", "scopes" ] } } } } } } } } }, async (c) => { const session = c.context.session; const accounts = await c.context.internalAdapter.findAccounts(session.user.id); return c.json(accounts.map((a) => { const { scope, ...parsed } = parseAccountOutput(c.context.options, a); return { ...parsed, scopes: scope?.split(",") || [] }; })); }); const linkSocialAccount = createAuthEndpoint("/link-social", { method: "POST", requireHeaders: true, body: z.object({ /** * Callback URL to redirect to after the user has signed in. */ callbackURL: z.string().meta({ description: "The URL to redirect to after the user has signed in" }).optional(), /** * OAuth2 provider to use */ provider: SocialProviderListEnum, /** * ID Token for direct authentication without redirect */ idToken: z.object({ token: z.string(), nonce: z.string().optional(), accessToken: z.string().optional(), refreshToken: z.string().optional(), scopes: z.array(z.string()).optional() }).optional(), /** * Whether to allow sign up for new users */ requestSignUp: z.boolean().optional(), /** * Additional scopes to request when linking the account. * This is useful for requesting additional permissions when * linking a social account compared to the initial authentication. */ scopes: z.array(z.string()).meta({ description: "Additional scopes to request from the provider" }).optional(), /** * The URL to redirect to if there is an error during the link process. */ errorCallbackURL: z.string().meta({ description: "The URL to redirect to if there is an error during the link process" }).optional(), /** * Disable automatic redirection to the provider * * This is useful if you want to handle the redirection * yourself like in a popup or a different tab. */ disableRedirect: z.boolean().meta({ description: "Disable automatic redirection to the provider. Useful for handling the redirection yourself" }).optional(), /** * Any additional data to pass through the oauth flow. */ additionalData: z.record(z.string(), z.any()).optional() }), use: [sessionMiddleware], metadata: { openapi: { description: "Link a social account to the user", operationId: "linkSocialAccount", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { url: { type: "string", description: "The authorization URL to redirect the user to" }, redirect: { type: "boolean", description: "Indicates if the user should be redirected to the authorization URL" }, status: { type: "boolean" } }, required: ["redirect"] } } } } } } } }, async (c) => { const session = c.context.session; const provider = await getAwaitableValue(c.context.socialProviders, { value: c.body.provider }); if (!provider) { c.context.logger.error("Provider not found. Make sure to add the provider in your auth config", { provider: c.body.provider }); throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.PROVIDER_NOT_FOUND); } if (c.body.idToken) { if (!provider.verifyIdToken) { c.context.logger.error("Provider does not support id token verification", { provider: c.body.provider }); throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.ID_TOKEN_NOT_SUPPORTED); } const { token, nonce } = c.body.idToken; if (!await provider.verifyIdToken(token, nonce, c)) { c.context.logger.warn("Invalid id token", { provider: c.body.provider }); throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.INVALID_TOKEN); } const linkingUserInfo = await provider.getUserInfo({ idToken: token, accessToken: c.body.idToken.accessToken, refreshToken: c.body.idToken.refreshToken }); if (!linkingUserInfo || !linkingUserInfo?.user) { c.context.logger.error("Failed to get user info", { provider: c.body.provider }); throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.FAILED_TO_GET_USER_INFO); } const linkingUserId = String(linkingUserInfo.user.id); if (!linkingUserInfo.user.email) { c.context.logger.error(missingEmailLogMessage(c.body.provider, { source: "id_token" }), { provider: c.body.provider }); throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.USER_EMAIL_NOT_FOUND); } if ((await c.context.internalAdapter.findAccounts(session.user.id)).find((a) => a.providerId === provider.id && a.accountId === linkingUserId)) return c.json({ url: "", status: true, redirect: false }); if (!c.context.trustedProviders.includes(provider.id) && !linkingUserInfo.user.emailVerified || c.context.options.account?.accountLinking?.enabled === false) throw APIError.from("UNAUTHORIZED", { message: "Account not linked - linking not allowed", code: "LINKING_NOT_ALLOWED" }); if (linkingUserInfo.user.email?.toLowerCase() !== session.user.email.toLowerCase() && c.context.options.account?.accountLinking?.allowDifferentEmails !== true) throw APIError.from("UNAUTHORIZED", { message: "Account not linked - different emails not allowed", code: "LINKING_DIFFERENT_EMAILS_NOT_ALLOWED" }); try { await c.context.internalAdapter.createAccount({ userId: session.user.id, providerId: provider.id, accountId: linkingUserId, accessToken: c.body.idToken.accessToken, idToken: token, refreshToken: c.body.idToken.refreshToken, scope: c.body.idToken.scopes?.join(",") }); } catch (_e) { throw APIError.from("EXPECTATION_FAILED", { message: "Account not linked - unable to create account", code: "LINKING_FAILED" }); } await applyUpdateUserInfoOnLink(c, session.user.id, linkingUserInfo.user); return c.json({ url: "", status: true, redirect: false }); } const state = await generateState(c, { userId: session.user.id, email: session.user.email }, c.body.additionalData); const url = await provider.createAuthorizationURL({ state: state.state, codeVerifier: state.codeVerifier, redirectURI: `${c.context.baseURL}/callback/${provider.id}`, scopes: c.body.scopes }); if (!c.body.disableRedirect) c.setHeader("Location", url.toString()); return c.json({ url: url.toString(), redirect: !c.body.disableRedirect }); }); const unlinkAccount = createAuthEndpoint("/unlink-account", { method: "POST", body: z.object({ providerId: z.string(), accountId: z.string().optional() }), use: [freshSessionMiddleware], metadata: { openapi: { description: "Unlink an account", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean" } } } } } } } } } }, async (ctx) => { const { providerId, accountId } = ctx.body; const accounts = await ctx.context.internalAdapter.findAccounts(ctx.context.session.user.id); if (accounts.length === 1 && !ctx.context.options.account?.accountLinking?.allowUnlinkingAll) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.FAILED_TO_UNLINK_LAST_ACCOUNT); const accountExist = accounts.find((account) => accountId ? account.accountId === accountId && account.providerId === providerId : account.providerId === providerId); if (!accountExist) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND); await ctx.context.internalAdapter.deleteAccount(accountExist.id); return ctx.json({ status: true }); }); /** * Resolves the user id an account-token operation should act on. * * A caller reaching the server over HTTP (a request or session headers are * present) must have a valid session, and that session's user always wins. * A trusted server-side `auth.api` caller with no session may instead name a * `userId` directly. Throws `UNAUTHORIZED` when an HTTP caller is * unauthenticated, and `USER_ID_OR_SESSION_REQUIRED` when neither a session * nor a `userId` is available. * * When a durable store is authoritative, bypasses the cookie cache: these * routes mint or refresh provider access tokens, so a server-side session * revocation must take effect immediately rather than waiting for the cached * cookie to expire. DB-less deployments keep the session in the cookie itself, * so the cache is left in place for them. */ async function resolveUserId(ctx, userId) { const session = await getSessionFromCtx(ctx, { disableCookieCache: isStateful(ctx) }); if (!session && (ctx.request || ctx.headers)) throw ctx.error("UNAUTHORIZED"); const resolvedUserId = session?.user?.id || userId; if (!resolvedUserId) throw APIError.from("BAD_REQUEST", { message: "Either userId or session is required", code: "USER_ID_OR_SESSION_REQUIRED" }); return resolvedUserId; } function matchesAccountSelection(ctx, account, { resolvedUserId, providerId, accountId }) { return (!shouldBindAccountCookieToSessionUser(ctx.context.options) || account.userId === resolvedUserId) && (!providerId || providerId === account.providerId) && (!accountId || account.accountId === accountId); } /** * Fetches a currently-valid access token for a user's provider account, * refreshing and persisting it when it is within five seconds of expiry. * Shared by the `/get-access-token` endpoint and `/account-info` so both * resolve and refresh tokens through one path. */ async function getValidAccessToken(ctx, { resolvedUserId, providerId, accountId, account: resolvedAccount }) { const provider = await getAwaitableValue(ctx.context.socialProviders, { value: providerId }); if (!provider) throw APIError.from("BAD_REQUEST", { message: `Provider ${providerId} is not supported.`, code: "PROVIDER_NOT_SUPPORTED" }); let account = resolvedAccount; if (!account) { const accountData = await getAccountCookie(ctx); if (accountData && matchesAccountSelection(ctx, accountData, { resolvedUserId, providerId, accountId })) account = accountData; else account = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).find((acc) => accountId ? acc.accountId === accountId && acc.providerId === providerId : acc.providerId === providerId); } if (!account) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND); try { let newTokens = null; const accessTokenExpired = account.accessTokenExpiresAt && new Date(account.accessTokenExpiresAt).getTime() - Date.now() < 5e3; if (account.refreshToken && accessTokenExpired && provider.refreshAccessToken) { const refreshToken = await decryptOAuthToken(account.refreshToken, ctx.context); newTokens = await provider.refreshAccessToken(refreshToken); const updatedData = { accessToken: await setTokenUtil(newTokens?.accessToken, ctx.context), accessTokenExpiresAt: newTokens?.accessTokenExpiresAt, refreshToken: newTokens?.refreshToken ? await setTokenUtil(newTokens.refreshToken, ctx.context) : account.refreshToken, refreshTokenExpiresAt: newTokens?.refreshTokenExpiresAt ?? account.refreshTokenExpiresAt, idToken: newTokens?.idToken || account.idToken }; let updatedAccount = null; if (account.id) updatedAccount = await ctx.context.internalAdapter.updateAccount(account.id, updatedData); if (ctx.context.options.account?.storeAccountCookie) await setAccountCookie(ctx, { ...account, ...updatedAccount ?? updatedData }); } const accessTokenExpiresAt = (() => { if (newTokens?.accessTokenExpiresAt) { if (typeof newTokens.accessTokenExpiresAt === "string") return new Date(newTokens.accessTokenExpiresAt); return newTokens.accessTokenExpiresAt; } if (account.accessTokenExpiresAt) { if (typeof account.accessTokenExpiresAt === "string") return new Date(account.accessTokenExpiresAt); return account.accessTokenExpiresAt; } })(); return { accessToken: newTokens?.accessToken ?? await decryptOAuthToken(account.accessToken ?? "", ctx.context), accessTokenExpiresAt, scopes: account.scope?.split(",") ?? [], idToken: newTokens?.idToken ?? account.idToken ?? void 0 }; } catch (_error) { throw APIError.from("BAD_REQUEST", { message: "Failed to get a valid access token", code: "FAILED_TO_GET_ACCESS_TOKEN" }); } } const getAccessToken = createAuthEndpoint("/get-access-token", { method: "POST", body: z.object({ providerId: z.string().meta({ description: "The provider ID for the OAuth provider" }), accountId: z.string().meta({ description: "The account ID associated with the refresh token" }).optional(), userId: z.string().meta({ description: "The user ID associated with the account" }).optional() }), metadata: { openapi: { description: "Get a valid access token, doing a refresh if needed", responses: { 200: { description: "A Valid access token", content: { "application/json": { schema: { type: "object", properties: { tokenType: { type: "string" }, idToken: { type: "string" }, accessToken: { type: "string" }, accessTokenExpiresAt: { type: "string", format: "date-time" } } } } } }, 400: { description: "Invalid refresh token or provider configuration" } } } } }, async (ctx) => { const { providerId, accountId, userId } = ctx.body || {}; const tokens = await getValidAccessToken(ctx, { resolvedUserId: await resolveUserId(ctx, userId), providerId, accountId }); return ctx.json(tokens); }); const refreshToken = createAuthEndpoint("/refresh-token", { method: "POST", body: z.object({ providerId: z.string().meta({ description: "The provider ID for the OAuth provider" }), accountId: z.string().meta({ description: "The account ID associated with the refresh token" }).optional(), userId: z.string().meta({ description: "The user ID associated with the account" }).optional() }), metadata: { openapi: { description: "Refresh the access token using a refresh token", responses: { 200: { description: "Access token refreshed successfully", content: { "application/json": { schema: { type: "object", properties: { tokenType: { type: "string" }, idToken: { type: "string" }, accessToken: { type: "string" }, refreshToken: { type: "string" }, accessTokenExpiresAt: { type: "string", format: "date-time" }, refreshTokenExpiresAt: { type: "string", format: "date-time" } } } } } }, 400: { description: "Invalid refresh token or provider configuration" } } } } }, async (ctx) => { const { providerId, accountId, userId } = ctx.body; const resolvedUserId = await resolveUserId(ctx, userId); const provider = await getAwaitableValue(ctx.context.socialProviders, { value: providerId }); if (!provider) throw APIError.from("BAD_REQUEST", { message: `Provider ${providerId} is not supported.`, code: "PROVIDER_NOT_SUPPORTED" }); if (!provider.refreshAccessToken) throw APIError.from("BAD_REQUEST", { message: `Provider ${providerId} does not support token refreshing.`, code: "TOKEN_REFRESH_NOT_SUPPORTED" }); let account = void 0; const accountData = await getAccountCookie(ctx); const usedAccountCookie = !!accountData && matchesAccountSelection(ctx, accountData, { resolvedUserId, providerId, accountId }); if (usedAccountCookie) account = accountData; else account = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).find((acc) => accountId ? acc.accountId === accountId && acc.providerId === providerId : acc.providerId === providerId); if (!account) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND); const refreshToken = account.refreshToken ?? void 0; if (!refreshToken) throw APIError.from("BAD_REQUEST", { message: "Refresh token not found", code: "REFRESH_TOKEN_NOT_FOUND" }); try { const decryptedRefreshToken = await decryptOAuthToken(refreshToken, ctx.context); const tokens = await provider.refreshAccessToken(decryptedRefreshToken); const resolvedRefreshToken = tokens.refreshToken ? await setTokenUtil(tokens.refreshToken, ctx.context) : refreshToken; const resolvedRefreshTokenExpiresAt = tokens.refreshTokenExpiresAt ?? account.refreshTokenExpiresAt; if (account.id) { const updateData = { ...account || {}, accessToken: await setTokenUtil(tokens.accessToken, ctx.context), refreshToken: resolvedRefreshToken, accessTokenExpiresAt: tokens.accessTokenExpiresAt, refreshTokenExpiresAt: resolvedRefreshTokenExpiresAt, scope: tokens.scopes?.join(",") || account.scope, idToken: tokens.idToken || account.idToken }; await ctx.context.internalAdapter.updateAccount(account.id, updateData); } if (usedAccountCookie && ctx.context.options.account?.storeAccountCookie) await setAccountCookie(ctx, { ...accountData, accessToken: await setTokenUtil(tokens.accessToken, ctx.context), refreshToken: resolvedRefreshToken, accessTokenExpiresAt: tokens.accessTokenExpiresAt, refreshTokenExpiresAt: resolvedRefreshTokenExpiresAt, scope: tokens.scopes?.join(",") || accountData.scope, idToken: tokens.idToken || accountData.idToken }); return ctx.json({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ?? decryptedRefreshToken, accessTokenExpiresAt: tokens.accessTokenExpiresAt, refreshTokenExpiresAt: resolvedRefreshTokenExpiresAt, scope: tokens.scopes?.join(",") || account.scope, idToken: tokens.idToken || account.idToken, providerId: account.providerId, accountId: account.accountId }); } catch (_error) { throw APIError.from("BAD_REQUEST", { message: "Failed to refresh access token", code: "FAILED_TO_REFRESH_ACCESS_TOKEN" }); } }); const accountInfo = createAuthEndpoint("/account-info", { method: "GET", metadata: { openapi: { description: "Get the account info provided by the provider", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { user: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, email: { type: "string" }, image: { type: "string" }, emailVerified: { type: "boolean" } }, required: ["id", "emailVerified"] }, data: { type: "object", properties: {}, additionalProperties: true } }, required: ["user", "data"], additionalProperties: false } } } } } } }, query: z.optional(z.object({ accountId: z.string().meta({ description: "The provider given account id for which to get the account info" }).optional(), providerId: z.string().meta({ description: "The provider ID to disambiguate provider-issued account IDs" }).optional(), userId: z.string().meta({ description: "The user ID associated with the account" }).optional() })) }, async (ctx) => { const { accountId: providedAccountId, providerId: providedProviderId, userId } = ctx.query || {}; const resolvedUserId = await resolveUserId(ctx, userId); let account = void 0; if (!providedAccountId) { if (ctx.context.options.account?.storeAccountCookie) { const accountData = await getAccountCookie(ctx); if (accountData && matchesAccountSelection(ctx, accountData, { resolvedUserId, providerId: providedProviderId })) account = accountData; } } else { const matchingAccounts = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).filter((acc) => acc.accountId === providedAccountId && (!providedProviderId || acc.providerId === providedProviderId)); if (matchingAccounts.length > 1) throw APIError.from("BAD_REQUEST", { message: "Multiple accounts share this account ID. Pass a providerId to disambiguate.", code: "AMBIGUOUS_ACCOUNT" }); account = matchingAccounts[0]; } if (!account || !matchesAccountSelection(ctx, account, { resolvedUserId })) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND); const provider = await getAwaitableValue(ctx.context.socialProviders, { value: account.providerId }); if (!provider) throw APIError.from("BAD_REQUEST", { message: "Account is not associated with a configured social provider.", code: "PROVIDER_NOT_CONFIGURED" }); const tokens = await getValidAccessToken(ctx, { resolvedUserId, providerId: account.providerId, accountId: account.accountId, account }); if (!tokens.accessToken) throw APIError.from("BAD_REQUEST", { message: "Access token not found", code: "ACCESS_TOKEN_NOT_FOUND" }); const info = await provider.getUserInfo({ ...tokens, accessToken: tokens.accessToken }); return ctx.json(info); }); //#endregion export { accountInfo, getAccessToken, linkSocialAccount, listUserAccounts, refreshToken, unlinkAccount };