Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions packages/integrations/better-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 8 additions & 37 deletions packages/integrations/better-auth/src/client.ts
Original file line number Diff line number Diff line change
@@ -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<typeof linkSocialAccount.options.body>,
'provider' | 'idToken'
>;

type LinkConnectResult<Throw extends boolean> = BetterFetchResponse<
{ url: string; redirect: boolean },
{ code: string; message: string },
Throw
>;

export function linkClient() {
return {
export const linkClient = () =>
({
id: 'link',
$InferServerPlugin: {} as ReturnType<typeof link>,
pathMethods: { '/link/disconnect': 'POST' },
getActions: ($fetch) => ({
link: {
connect: <Throw extends boolean = false>(
options: LinkConnectOptions = {},
fetchOptions?: Omit<BetterFetchOption, 'throw'> & { throw?: Throw },
) =>
$fetch('/link-social', {
...fetchOptions,
throw: fetchOptions?.throw ?? false,
method: 'POST',
body: { ...options, provider: 'link' },
}) as Promise<LinkConnectResult<Throw>>,
},
}),
} satisfies BetterAuthClientPlugin;
}
pathMethods: {
'/link/connect': 'POST',
'/link/disconnect': 'POST',
},
}) satisfies BetterAuthClientPlugin;
85 changes: 2 additions & 83 deletions packages/integrations/better-auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -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 });
},
);
}
124 changes: 124 additions & 0 deletions packages/integrations/better-auth/src/routes.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof linkSocialAccount>>;
};

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 });
},
);
43 changes: 32 additions & 11 deletions packages/integrations/better-auth/test/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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<typeof globalThis.fetch>()
Expand All @@ -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<typeof data>();
expect(unwrapped).toEqual(data);
const result = await client.link.connect();
expectTypeOf(result).toEqualTypeOf<typeof data>();
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 () => {
Expand Down