From 6fbdeb59d449cfb2d87e49d44fd49e064819e992 Mon Sep 17 00:00:00 2001 From: Taesu Date: Sat, 19 Sep 2026 16:23:07 +0900 Subject: [PATCH] feat(better-auth): add Link connect endpoint --- packages/integrations/better-auth/README.md | 13 +- .../integrations/better-auth/src/client.ts | 45 ++----- .../integrations/better-auth/src/index.ts | 85 +----------- .../integrations/better-auth/src/routes.ts | 124 ++++++++++++++++++ .../better-auth/test/link.test.ts | 43 ++++-- 5 files changed, 176 insertions(+), 134 deletions(-) create mode 100644 packages/integrations/better-auth/src/routes.ts 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/src/client.ts b/packages/integrations/better-auth/src/client.ts index 0762c48..cc93a8e 100644 --- a/packages/integrations/better-auth/src/client.ts +++ b/packages/integrations/better-auth/src/client.ts @@ -1,41 +1,12 @@ -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 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', + }, + }) satisfies BetterAuthClientPlugin; diff --git a/packages/integrations/better-auth/src/index.ts b/packages/integrations/better-auth/src/index.ts index e3383c4..49cd467 100644 --- a/packages/integrations/better-auth/src/index.ts +++ b/packages/integrations/better-auth/src/index.ts @@ -1,14 +1,8 @@ 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 { connectLink, disconnectLink } from './routes'; const linkProfileSchema = z.object({ id: z.string().min(1), @@ -69,83 +63,8 @@ export function link(options: LinkOptions) { ...oauth, id: 'link', endpoints: { + connectLink: connectLink(), disconnectLink: disconnectLink(options), }, } 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 }); - }, - ); -} diff --git a/packages/integrations/better-auth/src/routes.ts b/packages/integrations/better-auth/src/routes.ts new file mode 100644 index 0000000..a628939 --- /dev/null +++ b/packages/integrations/better-auth/src/routes.ts @@ -0,0 +1,124 @@ +import { + APIError, + createAuthEndpoint, + dispatchAuthEndpoint, + freshSessionMiddleware, + linkSocialAccount, + sensitiveSessionMiddleware, +} from 'better-auth/api'; +import { decryptOAuthToken } from 'better-auth/oauth2'; +import { z } from 'zod'; +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 dispatched = await dispatchAuthEndpoint(linkSocialAccount, { + asResponse: false, + body: { ...ctx.body, provider: 'link' }, + context: ctx.context, + headers: ctx.headers, + ...(ctx.request ? { request: ctx.request } : {}), + returnHeaders: true, + }); + const result = dispatched as { + headers: Headers | null; + response: Awaited>; + }; + + for (const cookie of result.headers?.getSetCookie() ?? []) { + ctx.responseHeaders.append('set-cookie', cookie); + } + result.headers?.forEach((value, key) => { + if (key.toLowerCase() !== 'set-cookie') { + ctx.responseHeaders.set(key, value); + } + }); + + return ctx.json(result.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 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 }); + }, + ); diff --git a/packages/integrations/better-auth/test/link.test.ts b/packages/integrations/better-auth/test/link.test.ts index ed4579e..9244113 100644 --- a/packages/integrations/better-auth/test/link.test.ts +++ b/packages/integrations/better-auth/test/link.test.ts @@ -531,7 +531,33 @@ it('uses native refresh and background token access, retaining rotated refresh t ).toBe(401); }); -describe('Link client actions', () => { +describe('Link actions', () => { + 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 +590,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 +600,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 () => {