diff --git a/packages/integrations/better-auth/README.md b/packages/integrations/better-auth/README.md index f3f7852..4307a95 100644 --- a/packages/integrations/better-auth/README.md +++ b/packages/integrations/better-auth/README.md @@ -56,11 +56,18 @@ After the user signs in to your app: await authClient.link.connect({ callbackURL: '/settings' }); ``` +For server-side usage: + +```ts +await auth.api.connectLink({ + body: { callbackURL: '/settings' }, + headers: await headers(), +}); +``` + This starts Better Auth's OAuth linking flow and returns the user to `/settings` after authorization. -`connect` returns `{ data, error }` by default, even if the client uses global -`throw: true`. Pass `{ throw: true }` as the second argument to receive data -directly and throw on errors. +`connect` follows Better Auth's configured client error handling. ## Disconnect a wallet diff --git a/packages/integrations/better-auth/package.json b/packages/integrations/better-auth/package.json index 94bceb2..7e0ee46 100644 --- a/packages/integrations/better-auth/package.json +++ b/packages/integrations/better-auth/package.json @@ -42,12 +42,14 @@ "test": "vitest run" }, "peerDependencies": { + "@better-auth/core": "^1.7.5", "better-auth": "^1.7.5" }, "dependencies": { "zod": "^4.5.4" }, "devDependencies": { + "@better-auth/core": "1.7.5", "@stripe/link-sdk": "workspace:*", "@stripe/link-typescript-config": "workspace:*", "@types/node": "^26.4.1", diff --git a/packages/integrations/better-auth/src/client.ts b/packages/integrations/better-auth/src/client.ts index 0762c48..c0755cc 100644 --- a/packages/integrations/better-auth/src/client.ts +++ b/packages/integrations/better-auth/src/client.ts @@ -1,41 +1,16 @@ -import type { linkSocialAccount } from 'better-auth/api'; -import type { - BetterAuthClientPlugin, - BetterFetchOption, - BetterFetchResponse, -} from 'better-auth/client'; -import type { z } from 'zod'; +import type { BetterAuthClientPlugin } from 'better-auth/client'; +import { LINK_ERROR_CODES } from './error-codes'; import type { link } from './index'; -export type LinkConnectOptions = Omit< - z.input, - 'provider' | 'idToken' ->; - -type LinkConnectResult = BetterFetchResponse< - { url: string; redirect: boolean }, - { code: string; message: string }, - Throw ->; - -export function linkClient() { - return { +export const linkClient = () => + ({ id: 'link', $InferServerPlugin: {} as ReturnType, - pathMethods: { '/link/disconnect': 'POST' }, - getActions: ($fetch) => ({ - link: { - connect: ( - options: LinkConnectOptions = {}, - fetchOptions?: Omit & { throw?: Throw }, - ) => - $fetch('/link-social', { - ...fetchOptions, - throw: fetchOptions?.throw ?? false, - method: 'POST', - body: { ...options, provider: 'link' }, - }) as Promise>, - }, - }), - } satisfies BetterAuthClientPlugin; -} + pathMethods: { + '/link/connect': 'POST', + '/link/disconnect': 'POST', + }, + $ERROR_CODES: LINK_ERROR_CODES, + }) satisfies BetterAuthClientPlugin; + +export { LINK_ERROR_CODES } from './error-codes'; diff --git a/packages/integrations/better-auth/src/error-codes.ts b/packages/integrations/better-auth/src/error-codes.ts new file mode 100644 index 0000000..2ccd4f7 --- /dev/null +++ b/packages/integrations/better-auth/src/error-codes.ts @@ -0,0 +1,8 @@ +import { defineErrorCodes } from 'better-auth'; + +export const LINK_ERROR_CODES = defineErrorCodes({ + LINK_REFRESH_TOKEN_NOT_FOUND: + 'Link refresh token is missing. The account remains connected.', + LINK_REVOCATION_FAILED: + 'Unable to revoke Link access. The account remains connected; try again.', +}); diff --git a/packages/integrations/better-auth/src/index.ts b/packages/integrations/better-auth/src/index.ts index e3383c4..1671ab7 100644 --- a/packages/integrations/better-auth/src/index.ts +++ b/packages/integrations/better-auth/src/index.ts @@ -1,14 +1,17 @@ import type { UserInfo } from '@stripe/link-sdk'; import type { BetterAuthPlugin } from 'better-auth'; -import { - APIError, - createAuthEndpoint, - freshSessionMiddleware, - sensitiveSessionMiddleware, -} from 'better-auth/api'; -import { decryptOAuthToken } from 'better-auth/oauth2'; import { genericOAuth } from 'better-auth/plugins/generic-oauth'; import { z } from 'zod'; +import { LINK_ERROR_CODES } from './error-codes'; +import { connectLink, disconnectLink } from './routes'; + +declare module '@better-auth/core' { + interface BetterAuthPluginRegistry { + link: { + creator: typeof link; + }; + } +} const linkProfileSchema = z.object({ id: z.string().min(1), @@ -69,83 +72,11 @@ export function link(options: LinkOptions) { ...oauth, id: 'link', endpoints: { + connectLink: connectLink(), disconnectLink: disconnectLink(options), }, + $ERROR_CODES: LINK_ERROR_CODES, } satisfies BetterAuthPlugin; } -function disconnectLink(options: LinkOptions) { - return createAuthEndpoint( - '/link/disconnect', - { - method: 'POST', - requireHeaders: true, - body: z.strictObject({ accountId: z.string().min(1) }), - use: [sensitiveSessionMiddleware, freshSessionMiddleware], - }, - async (ctx) => { - const accounts = await ctx.context.internalAdapter.findAccounts( - ctx.context.session.user.id, - ); - const account = accounts.find( - (candidate) => - candidate.id === ctx.body.accountId && - candidate.providerId === 'link', - ); - if (!account) { - throw new APIError('BAD_REQUEST', { - code: 'ACCOUNT_NOT_FOUND', - message: 'Link account not found.', - }); - } - if ( - accounts.length === 1 && - !ctx.context.options.account?.accountLinking?.allowUnlinkingAll - ) { - throw new APIError('BAD_REQUEST', { - code: 'FAILED_TO_UNLINK_LAST_ACCOUNT', - message: 'Add another sign-in method before disconnecting Link.', - }); - } - if (!account.refreshToken) { - throw new APIError('BAD_REQUEST', { - code: 'LINK_REFRESH_TOKEN_NOT_FOUND', - message: - 'Link refresh token is missing. The account remains connected.', - }); - } - - try { - const token = await decryptOAuthToken( - account.refreshToken, - ctx.context, - ); - const response = await fetch('https://login.link.com/auth/revoke', { - method: 'POST', - redirect: 'error', - signal: AbortSignal.timeout(10_000), - headers: { - Authorization: `Bearer ${options.publishableKey}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - client_id: options.clientId, - client_secret: options.clientSecret, - token, - token_type_hint: 'refresh_token', - }), - }); - if (!response.ok) throw new Error('Revocation rejected'); - } catch { - throw new APIError('BAD_GATEWAY', { - code: 'LINK_REVOCATION_FAILED', - message: - 'Unable to revoke Link access. The account remains connected; try again.', - }); - } - - await ctx.context.internalAdapter.deleteAccount(account.id); - return ctx.json({ status: true }); - }, - ); -} +export { LINK_ERROR_CODES } from './error-codes'; diff --git a/packages/integrations/better-auth/src/routes.ts b/packages/integrations/better-auth/src/routes.ts new file mode 100644 index 0000000..4c87911 --- /dev/null +++ b/packages/integrations/better-auth/src/routes.ts @@ -0,0 +1,122 @@ +import { BASE_ERROR_CODES } from 'better-auth'; +import { + APIError, + createAuthEndpoint, + freshSessionMiddleware, + linkSocialAccount, + sensitiveSessionMiddleware, +} from 'better-auth/api'; +import { decryptOAuthToken } from 'better-auth/oauth2'; +import { z } from 'zod'; +import { LINK_ERROR_CODES } from './error-codes'; +import type { LinkOptions } from './index'; + +export const connectLink = () => + createAuthEndpoint( + '/link/connect', + { + method: 'POST', + requireHeaders: true, + body: linkSocialAccount.options.body.omit({ + provider: true, + idToken: true, + }), + }, + async (ctx) => { + const { headers, response } = await linkSocialAccount({ + body: { ...ctx.body, provider: 'link' }, + context: ctx.context, + headers: ctx.headers, + ...(ctx.request ? { request: ctx.request } : {}), + returnHeaders: true, + }); + + // Preserve OAuth state cookies and response headers from linkSocialAccount. + for (const cookie of headers.getSetCookie()) { + ctx.responseHeaders.append('set-cookie', cookie); + } + headers.forEach((value, key) => { + if (key.toLowerCase() !== 'set-cookie') { + ctx.responseHeaders.set(key, value); + } + }); + + return ctx.json(response); + }, + ); + +export const disconnectLink = (options: LinkOptions) => + createAuthEndpoint( + '/link/disconnect', + { + method: 'POST', + requireHeaders: true, + body: z.strictObject({ accountId: z.string().min(1) }), + use: [sensitiveSessionMiddleware, freshSessionMiddleware], + }, + async (ctx) => { + const accounts = await ctx.context.internalAdapter.findAccounts( + ctx.context.session.user.id, + ); + const account = accounts.find( + (candidate) => + candidate.id === ctx.body.accountId && + candidate.providerId === 'link', + ); + if (!account) { + throw APIError.from('BAD_REQUEST', BASE_ERROR_CODES.ACCOUNT_NOT_FOUND); + } + if ( + accounts.length === 1 && + !ctx.context.options.account?.accountLinking?.allowUnlinkingAll + ) { + throw APIError.from( + 'BAD_REQUEST', + BASE_ERROR_CODES.FAILED_TO_UNLINK_LAST_ACCOUNT, + ); + } + if (!account.refreshToken) { + throw APIError.from( + 'BAD_REQUEST', + LINK_ERROR_CODES.LINK_REFRESH_TOKEN_NOT_FOUND, + ); + } + + let response: Response; + try { + const token = await decryptOAuthToken( + account.refreshToken, + ctx.context, + ); + response = await fetch('https://login.link.com/auth/revoke', { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(10_000), + headers: { + Authorization: `Bearer ${options.publishableKey}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: options.clientId, + client_secret: options.clientSecret, + token, + token_type_hint: 'refresh_token', + }), + }); + } catch { + throw APIError.from( + 'BAD_GATEWAY', + LINK_ERROR_CODES.LINK_REVOCATION_FAILED, + ); + } + if (!response.ok) { + throw APIError.from( + 'BAD_GATEWAY', + LINK_ERROR_CODES.LINK_REVOCATION_FAILED, + ); + } + + await ctx.context.internalAdapter.deleteAccount(account.id); + return ctx.json({ status: true }); + }, + ); diff --git a/packages/integrations/better-auth/test/link.test.ts b/packages/integrations/better-auth/test/link.test.ts index ed4579e..6cba101 100644 --- a/packages/integrations/better-auth/test/link.test.ts +++ b/packages/integrations/better-auth/test/link.test.ts @@ -531,7 +531,50 @@ it('uses native refresh and background token access, retaining rotated refresh t ).toBe(401); }); -describe('Link client actions', () => { +describe('Link actions', () => { + it('registers the plugin and error codes with Better Auth', async () => { + const f = await fixture(); + const ctx = await f.auth.$context; + const plugin = ctx.getPlugin('link'); + + expectTypeOf(plugin).toEqualTypeOf | null>(); + expect(plugin?.id).toBe('link'); + expect(f.auth.$ERROR_CODES.LINK_REVOCATION_FAILED).toMatchObject({ + code: 'LINK_REVOCATION_FAILED', + message: + 'Unable to revoke Link access. The account remains connected; try again.', + }); + expectTypeOf( + f.client.$ERROR_CODES.LINK_REVOCATION_FAILED.code, + ).toEqualTypeOf<'LINK_REVOCATION_FAILED'>(); + }); + + it('starts the same Link connection through the server API', async () => { + const f = await fixture(); + const result = await f.auth.api.connectLink({ + body: { + callbackURL: '/settings', + disableRedirect: true, + }, + headers: { + cookie: f.cookies.toString(), + origin: 'http://localhost:3000', + }, + returnHeaders: true, + }); + + expectTypeOf(f.auth.api.connectLink).toBeFunction(); + expect(result.response.redirect).toBe(false); + expect(new URL(result.response.url).searchParams.get('client_id')).toBe( + credentials.clientId, + ); + const responseHeaders = result.headers ?? new Headers(); + expect(responseHeaders.getSetCookie().length).toBeGreaterThan(0); + f.cookies.absorb(new Response(null, { headers: responseHeaders })); + const callback = await f.complete(new URL(result.response.url)); + expect(callback.headers.get('location')).toBe('/settings'); + }); + it('connect forwards OAuth options and uses the existing client fetch configuration', async () => { const f = await fixture('database', '/custom/auth', { scopes: ['userinfo:read'], @@ -564,7 +607,7 @@ describe('Link client actions', () => { ).toBe(403); }); - it('connect types and returns the requested response shape', async () => { + it('connect follows the configured client error handling', async () => { const data = { url: 'https://login.link.com/auth', redirect: false }; const fetch = vi .fn() @@ -574,21 +617,16 @@ describe('Link client actions', () => { plugins: [linkClient()], fetchOptions: { throw: true, customFetchImpl: fetch }, }); - const wrapped = await client.link.connect(); - expect(wrapped.data).toEqual(data); - const unwrapped = await client.link.connect({}, { throw: true }); - expectTypeOf(unwrapped).toEqualTypeOf(); - expect(unwrapped).toEqual(data); + const result = await client.link.connect(); + expectTypeOf(result).toEqualTypeOf(); + expect(result).toEqual(data); fetch.mockImplementation(async () => Response.json( { code: 'UNAUTHORIZED', message: 'Unauthorized' }, { status: 401 }, ), ); - expect((await client.link.connect()).error?.code).toBe('UNAUTHORIZED'); - await expect( - client.link.connect({}, { throw: true }), - ).rejects.toMatchObject({ status: 401 }); + await expect(client.link.connect()).rejects.toMatchObject({ status: 401 }); }); it('connect requires an authenticated app user', async () => { @@ -690,18 +728,19 @@ describe('Link client actions', () => { ); if (!ownPasswordAccount) throw new Error('Expected a password account'); const callCount = f.fetchMock.mock.calls.length; - expect( - (await f.client.link.disconnect({ accountId: ownPasswordAccount.id })) - .error?.code, - ).toBe('ACCOUNT_NOT_FOUND'); - expect( - (await f.client.link.disconnect({ accountId: account.accountId })).error - ?.code, - ).toBe('ACCOUNT_NOT_FOUND'); + const passwordAccountResult = await f.client.link.disconnect({ + accountId: ownPasswordAccount.id, + }); + expect(passwordAccountResult.error?.code).toBe('ACCOUNT_NOT_FOUND'); + const providerAccountResult = await f.client.link.disconnect({ + accountId: account.accountId, + }); + expect(providerAccountResult.error?.code).toBe('ACCOUNT_NOT_FOUND'); await f.signUp('another@example.com'); - expect( - (await f.client.link.disconnect({ accountId: account.id })).error?.code, - ).toBe('ACCOUNT_NOT_FOUND'); + const anotherUserResult = await f.client.link.disconnect({ + accountId: account.id, + }); + expect(anotherUserResult.error?.code).toBe('ACCOUNT_NOT_FOUND'); expect(f.fetchMock).toHaveBeenCalledTimes(callCount); expect( f.database @@ -802,9 +841,8 @@ describe('Link client actions', () => { const ctx = await f.auth.$context; await ctx.internalAdapter.updateAccount(account.id, { refreshToken: null }); const callCount = f.fetchMock.mock.calls.length; - expect( - (await f.client.link.disconnect({ accountId: account.id })).error?.code, - ).toBe('LINK_REFRESH_TOKEN_NOT_FOUND'); + const result = await f.client.link.disconnect({ accountId: account.id }); + expect(result.error?.code).toBe('LINK_REFRESH_TOKEN_NOT_FOUND'); expect(f.fetchMock).toHaveBeenCalledTimes(callCount); expect( f.database.prepare('select id from account where id = ?').get(account.id), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20aac8a..09b9e41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: specifier: ^4.5.4 version: 4.5.4 devDependencies: + '@better-auth/core': + specifier: 1.7.5 + version: 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.5.4))(jose@6.2.11)(kysely@0.29.5)(nanostores@1.5.3) '@stripe/link-sdk': specifier: workspace:* version: link:../../sdk