@stripe/link-integrations-better-auth 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stripe, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # Link for Better Auth
2
+
3
+ Sign in with Link or connect a Link wallet to an existing user. Better Auth manages OAuth, token storage, and refresh.
4
+
5
+ ## Setup
6
+
7
+ Requires Node.js 22+ and Better Auth 1.7.5+.
8
+
9
+ ```sh
10
+ pnpm add @stripe/link-integrations-better-auth better-auth
11
+ ```
12
+
13
+ [Register a Link OAuth client](https://docs.stripe.com/agentic-commerce/link-cli/oauth) with the callback URL `https://yourapp.com/api/auth/callback/link`. Adjust the path if you use a custom Better Auth base path, and register any local development callback too.
14
+
15
+ Add the plugin to your server configuration:
16
+
17
+ ```ts
18
+ import { betterAuth } from 'better-auth';
19
+ import { link } from '@stripe/link-integrations-better-auth';
20
+
21
+ export const auth = betterAuth({
22
+ account: {
23
+ encryptOAuthTokens: true,
24
+ accountLinking: {
25
+ trustedProviders: ['link'],
26
+ // Allow the wallet email to differ from the app user's email.
27
+ allowDifferentEmails: true,
28
+ },
29
+ },
30
+ plugins: [
31
+ link({
32
+ clientId: process.env.LINK_CLIENT_ID!,
33
+ clientSecret: process.env.LINK_CLIENT_SECRET!,
34
+ publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!,
35
+ }),
36
+ ],
37
+ });
38
+ ```
39
+
40
+ Then register the client plugin:
41
+
42
+ ```ts
43
+ import { linkClient } from '@stripe/link-integrations-better-auth/client';
44
+ import { createAuthClient } from 'better-auth/react';
45
+
46
+ export const authClient = createAuthClient({ plugins: [linkClient()] });
47
+ ```
48
+
49
+ Default scopes are `payment_methods.agentic` and `userinfo:read`. For sign-in only, set `scopes: ['userinfo:read']` in `link(...)`. Keep `userinfo:read` when customizing scopes.
50
+
51
+ ## Connect a wallet
52
+
53
+ After the user signs in to your app:
54
+
55
+ ```ts
56
+ await authClient.link.connect({ callbackURL: '/settings' });
57
+ ```
58
+
59
+ For server-side usage:
60
+
61
+ ```ts
62
+ await auth.api.connectLink({
63
+ body: { callbackURL: '/settings' },
64
+ headers: await headers(),
65
+ });
66
+ ```
67
+
68
+ This starts Better Auth's OAuth linking flow and returns the user to `/settings` after authorization.
69
+
70
+ `connect` follows Better Auth's configured client error handling.
71
+
72
+ ## Disconnect a wallet
73
+
74
+ Find the connected account and pass its Better Auth record ID:
75
+
76
+ ```ts
77
+ const { data: accounts, error } = await authClient.listAccounts();
78
+ if (error) throw new Error(error.message);
79
+
80
+ const account = accounts?.find((account) => account.providerId === 'link');
81
+ if (account) {
82
+ const { error } = await authClient.link.disconnect({ accountId: account.id });
83
+ if (error) throw new Error(error.message);
84
+ }
85
+ ```
86
+
87
+ Disconnect requires a fresh session and protects the user's last sign-in method by default. It revokes Link access before deleting the local account; failed revocation keeps the connection intact for retry.
88
+
89
+ Use `account.id` for account operations. `account.accountId` is the stable Link user ID.
90
+
91
+ ## Sign in with Link
92
+
93
+ To let users sign in or create an app account with Link:
94
+
95
+ ```ts
96
+ await authClient.signIn.social({
97
+ provider: 'link',
98
+ callbackURL: '/dashboard',
99
+ });
100
+ ```
101
+
102
+ Better Auth's account-linking and email-verification policies apply. Use `authClient.accountInfo()` for the connected profile and server-side `auth.api.getAccessToken()` for Link API calls.
103
+
104
+ ## Example
105
+
106
+ See the [Next.js example](example/README.md) for a runnable app with sign-in, wallet connection, and disconnect.
@@ -0,0 +1,11 @@
1
+ // src/error-codes.ts
2
+ import { defineErrorCodes } from "better-auth";
3
+ var LINK_ERROR_CODES = defineErrorCodes({
4
+ LINK_REFRESH_TOKEN_NOT_FOUND: "Link refresh token is missing. The account remains connected.",
5
+ LINK_REVOCATION_FAILED: "Unable to revoke Link access. The account remains connected; try again."
6
+ });
7
+
8
+ export {
9
+ LINK_ERROR_CODES
10
+ };
11
+ //# sourceMappingURL=chunk-POKWE5ZI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/error-codes.ts"],"sourcesContent":["import { defineErrorCodes } from 'better-auth';\n\nexport const LINK_ERROR_CODES = defineErrorCodes({\n LINK_REFRESH_TOKEN_NOT_FOUND:\n 'Link refresh token is missing. The account remains connected.',\n LINK_REVOCATION_FAILED:\n 'Unable to revoke Link access. The account remains connected; try again.',\n});\n"],"mappings":";AAAA,SAAS,wBAAwB;AAE1B,IAAM,mBAAmB,iBAAiB;AAAA,EAC/C,8BACE;AAAA,EACF,wBACE;AACJ,CAAC;","names":[]}
@@ -0,0 +1,15 @@
1
+ import type { link } from './index';
2
+ export declare const linkClient: () => {
3
+ id: "link";
4
+ $InferServerPlugin: ReturnType<typeof link>;
5
+ pathMethods: {
6
+ '/link/connect': "POST";
7
+ '/link/disconnect': "POST";
8
+ };
9
+ $ERROR_CODES: {
10
+ LINK_REFRESH_TOKEN_NOT_FOUND: import("better-auth").RawError<"LINK_REFRESH_TOKEN_NOT_FOUND">;
11
+ LINK_REVOCATION_FAILED: import("better-auth").RawError<"LINK_REVOCATION_FAILED">;
12
+ };
13
+ };
14
+ export { LINK_ERROR_CODES } from './error-codes';
15
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAEpC,eAAO,MAAM,UAAU;;wBAGO,UAAU,CAAC,OAAO,IAAI,CAAC;;;;;;;;;CAMhB,CAAC;AAEtC,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC"}
package/dist/client.js ADDED
@@ -0,0 +1,19 @@
1
+ import {
2
+ LINK_ERROR_CODES
3
+ } from "./chunk-POKWE5ZI.js";
4
+
5
+ // src/client.ts
6
+ var linkClient = () => ({
7
+ id: "link",
8
+ $InferServerPlugin: {},
9
+ pathMethods: {
10
+ "/link/connect": "POST",
11
+ "/link/disconnect": "POST"
12
+ },
13
+ $ERROR_CODES: LINK_ERROR_CODES
14
+ });
15
+ export {
16
+ LINK_ERROR_CODES,
17
+ linkClient
18
+ };
19
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts"],"sourcesContent":["import type { BetterAuthClientPlugin } from 'better-auth/client';\nimport { LINK_ERROR_CODES } from './error-codes';\nimport type { link } from './index';\n\nexport const linkClient = () =>\n ({\n id: 'link',\n $InferServerPlugin: {} as ReturnType<typeof link>,\n pathMethods: {\n '/link/connect': 'POST',\n '/link/disconnect': 'POST',\n },\n $ERROR_CODES: LINK_ERROR_CODES,\n }) satisfies BetterAuthClientPlugin;\n\nexport { LINK_ERROR_CODES } from './error-codes';\n"],"mappings":";;;;;AAIO,IAAM,aAAa,OACvB;AAAA,EACC,IAAI;AAAA,EACJ,oBAAoB,CAAC;AAAA,EACrB,aAAa;AAAA,IACX,iBAAiB;AAAA,IACjB,oBAAoB;AAAA,EACtB;AAAA,EACA,cAAc;AAChB;","names":[]}
@@ -0,0 +1,5 @@
1
+ export declare const LINK_ERROR_CODES: {
2
+ LINK_REFRESH_TOKEN_NOT_FOUND: import("better-auth").RawError<"LINK_REFRESH_TOKEN_NOT_FOUND">;
3
+ LINK_REVOCATION_FAILED: import("better-auth").RawError<"LINK_REVOCATION_FAILED">;
4
+ };
5
+ //# sourceMappingURL=error-codes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-codes.d.ts","sourceRoot":"","sources":["../src/error-codes.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,gBAAgB;;;CAK3B,CAAC"}
@@ -0,0 +1,82 @@
1
+ import { z } from 'zod';
2
+ declare module '@better-auth/core' {
3
+ interface BetterAuthPluginRegistry<AuthOptions, Options> {
4
+ link: {
5
+ creator: typeof link;
6
+ };
7
+ }
8
+ }
9
+ export interface LinkOptions {
10
+ clientId: string;
11
+ clientSecret: string;
12
+ publishableKey: string;
13
+ scopes?: string[];
14
+ redirectURI?: string;
15
+ }
16
+ export declare function link(options: LinkOptions): {
17
+ version: string;
18
+ init: (ctx: import("better-auth").AuthContext) => Promise<{
19
+ context: {
20
+ socialProviders: import("better-auth").OAuthProvider<object, Partial<import("better-auth").ProviderOptions<object>>>[];
21
+ };
22
+ }>;
23
+ options: import("better-auth/plugins/generic-oauth").GenericOAuthOptions<"link">;
24
+ id: "link";
25
+ endpoints: {
26
+ connectLink: import("better-auth").StrictEndpoint<"/link/connect", {
27
+ method: "POST";
28
+ requireHeaders: true;
29
+ body: z.ZodObject<{
30
+ callbackURL: z.ZodOptional<z.ZodString>;
31
+ requestSignUp: z.ZodOptional<z.ZodBoolean>;
32
+ scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
33
+ errorCallbackURL: z.ZodOptional<z.ZodString>;
34
+ disableRedirect: z.ZodOptional<z.ZodBoolean>;
35
+ loginHint: z.ZodOptional<z.ZodString>;
36
+ additionalParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
37
+ additionalData: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
38
+ }, z.core.$strip>;
39
+ }, {
40
+ url: string;
41
+ redirect: boolean;
42
+ }>;
43
+ disconnectLink: import("better-auth").StrictEndpoint<"/link/disconnect", {
44
+ method: "POST";
45
+ requireHeaders: true;
46
+ body: z.ZodObject<{
47
+ accountId: z.ZodString;
48
+ }, z.core.$strict>;
49
+ use: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<{
50
+ session: {
51
+ session: Record<string, any> & {
52
+ id: string;
53
+ createdAt: Date;
54
+ updatedAt: Date;
55
+ userId: string;
56
+ expiresAt: Date;
57
+ token: string;
58
+ ipAddress?: string | null | undefined;
59
+ userAgent?: string | null | undefined;
60
+ };
61
+ user: Record<string, any> & {
62
+ id: string;
63
+ createdAt: Date;
64
+ updatedAt: Date;
65
+ email: string;
66
+ emailVerified: boolean;
67
+ name: string;
68
+ image?: string | null | undefined;
69
+ };
70
+ };
71
+ }>>[];
72
+ }, {
73
+ status: boolean;
74
+ }>;
75
+ };
76
+ $ERROR_CODES: {
77
+ LINK_REFRESH_TOKEN_NOT_FOUND: import("better-auth").RawError<"LINK_REFRESH_TOKEN_NOT_FOUND">;
78
+ LINK_REVOCATION_FAILED: import("better-auth").RawError<"LINK_REVOCATION_FAILED">;
79
+ };
80
+ };
81
+ export { LINK_ERROR_CODES } from './error-codes';
82
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,QAAQ,mBAAmB,CAAC;IACjC,UAAU,wBAAwB,CAAC,WAAW,EAAE,OAAO;QACrD,IAAI,EAAE;YACJ,OAAO,EAAE,OAAO,IAAI,CAAC;SACtB,CAAC;KACH;CACF;AAMD,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,wBAAgB,IAAI,CAAC,OAAO,EAAE,WAAW;;;;;;;;;;QA+CnC,WAAW;;;;;;;;;;;;;;;;;QACX,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAInB;AAED,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,171 @@
1
+ import {
2
+ LINK_ERROR_CODES
3
+ } from "./chunk-POKWE5ZI.js";
4
+
5
+ // src/index.ts
6
+ import { genericOAuth } from "better-auth/plugins/generic-oauth";
7
+ import { z as z2 } from "zod";
8
+
9
+ // src/routes.ts
10
+ import { BASE_ERROR_CODES } from "better-auth";
11
+ import {
12
+ APIError,
13
+ createAuthEndpoint,
14
+ freshSessionMiddleware,
15
+ linkSocialAccount,
16
+ sensitiveSessionMiddleware
17
+ } from "better-auth/api";
18
+ import { decryptOAuthToken } from "better-auth/oauth2";
19
+ import { z } from "zod";
20
+ var connectLink = () => createAuthEndpoint(
21
+ "/link/connect",
22
+ {
23
+ method: "POST",
24
+ requireHeaders: true,
25
+ body: linkSocialAccount.options.body.omit({
26
+ provider: true,
27
+ idToken: true
28
+ })
29
+ },
30
+ async (ctx) => {
31
+ const { headers, response } = await linkSocialAccount({
32
+ body: { ...ctx.body, provider: "link" },
33
+ context: ctx.context,
34
+ headers: ctx.headers,
35
+ ...ctx.request ? { request: ctx.request } : {},
36
+ returnHeaders: true
37
+ });
38
+ for (const cookie of headers.getSetCookie()) {
39
+ ctx.responseHeaders.append("set-cookie", cookie);
40
+ }
41
+ headers.forEach((value, key) => {
42
+ if (key.toLowerCase() !== "set-cookie") {
43
+ ctx.responseHeaders.set(key, value);
44
+ }
45
+ });
46
+ return ctx.json(response);
47
+ }
48
+ );
49
+ var disconnectLink = (options) => createAuthEndpoint(
50
+ "/link/disconnect",
51
+ {
52
+ method: "POST",
53
+ requireHeaders: true,
54
+ body: z.strictObject({ accountId: z.string().min(1) }),
55
+ use: [sensitiveSessionMiddleware, freshSessionMiddleware]
56
+ },
57
+ async (ctx) => {
58
+ const accounts = await ctx.context.internalAdapter.findAccounts(
59
+ ctx.context.session.user.id
60
+ );
61
+ const account = accounts.find(
62
+ (candidate) => candidate.id === ctx.body.accountId && candidate.providerId === "link"
63
+ );
64
+ if (!account) {
65
+ throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
66
+ }
67
+ if (accounts.length === 1 && !ctx.context.options.account?.accountLinking?.allowUnlinkingAll) {
68
+ throw APIError.from(
69
+ "BAD_REQUEST",
70
+ BASE_ERROR_CODES.FAILED_TO_UNLINK_LAST_ACCOUNT
71
+ );
72
+ }
73
+ if (!account.refreshToken) {
74
+ throw APIError.from(
75
+ "BAD_REQUEST",
76
+ LINK_ERROR_CODES.LINK_REFRESH_TOKEN_NOT_FOUND
77
+ );
78
+ }
79
+ let response;
80
+ try {
81
+ const token = await decryptOAuthToken(
82
+ account.refreshToken,
83
+ ctx.context
84
+ );
85
+ response = await fetch("https://login.link.com/auth/revoke", {
86
+ method: "POST",
87
+ redirect: "error",
88
+ signal: AbortSignal.timeout(1e4),
89
+ headers: {
90
+ Authorization: `Bearer ${options.publishableKey}`,
91
+ "Content-Type": "application/x-www-form-urlencoded"
92
+ },
93
+ body: new URLSearchParams({
94
+ client_id: options.clientId,
95
+ client_secret: options.clientSecret,
96
+ token,
97
+ token_type_hint: "refresh_token"
98
+ })
99
+ });
100
+ } catch {
101
+ throw APIError.from(
102
+ "BAD_GATEWAY",
103
+ LINK_ERROR_CODES.LINK_REVOCATION_FAILED
104
+ );
105
+ }
106
+ if (!response.ok) {
107
+ throw APIError.from(
108
+ "BAD_GATEWAY",
109
+ LINK_ERROR_CODES.LINK_REVOCATION_FAILED
110
+ );
111
+ }
112
+ await ctx.context.internalAdapter.deleteAccount(account.id);
113
+ return ctx.json({ status: true });
114
+ }
115
+ );
116
+
117
+ // src/index.ts
118
+ var linkProfileSchema = z2.object({
119
+ id: z2.string().min(1)
120
+ });
121
+ function link(options) {
122
+ for (const field of ["clientId", "clientSecret", "publishableKey"]) {
123
+ if (!options[field]?.trim()) throw new Error(`Link ${field} is required.`);
124
+ }
125
+ const oauth = genericOAuth({
126
+ config: [
127
+ {
128
+ providerId: "link",
129
+ name: "Link",
130
+ clientId: options.clientId,
131
+ clientSecret: options.clientSecret,
132
+ redirectURI: options.redirectURI,
133
+ authorizationUrl: "https://login.link.com/auth",
134
+ tokenUrl: "https://login.link.com/auth/token",
135
+ userInfoUrl: "https://api.link.com/userinfo",
136
+ authorizationUrlParams: { key: options.publishableKey },
137
+ scopes: options.scopes ?? ["payment_methods.agentic", "userinfo:read"],
138
+ pkce: true,
139
+ accessTokenExpiresIn: 3600,
140
+ tokenEndpointAuth: {
141
+ method: "custom",
142
+ customizeRequest({ body, headers }) {
143
+ body.set("client_id", options.clientId);
144
+ body.set("client_secret", options.clientSecret);
145
+ headers.authorization = `Bearer ${options.publishableKey}`;
146
+ }
147
+ },
148
+ accountSubject: ({ profile }) => linkProfileSchema.parse(profile).id,
149
+ mapProfileToUser(profile) {
150
+ return {
151
+ name: profile.name || [profile.first_name, profile.last_name].filter((part) => typeof part === "string").join(" ") || "Link"
152
+ };
153
+ }
154
+ }
155
+ ]
156
+ });
157
+ return {
158
+ ...oauth,
159
+ id: "link",
160
+ endpoints: {
161
+ connectLink: connectLink(),
162
+ disconnectLink: disconnectLink(options)
163
+ },
164
+ $ERROR_CODES: LINK_ERROR_CODES
165
+ };
166
+ }
167
+ export {
168
+ LINK_ERROR_CODES,
169
+ link
170
+ };
171
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/routes.ts"],"sourcesContent":["import type { UserInfo } from '@stripe/link-sdk';\nimport type { BetterAuthPlugin } from 'better-auth';\nimport { genericOAuth } from 'better-auth/plugins/generic-oauth';\nimport { z } from 'zod';\nimport { LINK_ERROR_CODES } from './error-codes';\nimport { connectLink, disconnectLink } from './routes';\n\ndeclare module '@better-auth/core' {\n interface BetterAuthPluginRegistry<AuthOptions, Options> {\n link: {\n creator: typeof link;\n };\n }\n}\n\nconst linkProfileSchema = z.object({\n id: z.string().min(1),\n}) satisfies z.ZodType<Pick<UserInfo, 'id'>>;\n\nexport interface LinkOptions {\n clientId: string;\n clientSecret: string;\n publishableKey: string;\n scopes?: string[];\n redirectURI?: string;\n}\n\nexport function link(options: LinkOptions) {\n for (const field of ['clientId', 'clientSecret', 'publishableKey'] as const) {\n if (!options[field]?.trim()) throw new Error(`Link ${field} is required.`);\n }\n\n const oauth = genericOAuth({\n config: [\n {\n providerId: 'link',\n name: 'Link',\n clientId: options.clientId,\n clientSecret: options.clientSecret,\n redirectURI: options.redirectURI,\n authorizationUrl: 'https://login.link.com/auth',\n tokenUrl: 'https://login.link.com/auth/token',\n userInfoUrl: 'https://api.link.com/userinfo',\n authorizationUrlParams: { key: options.publishableKey },\n scopes: options.scopes ?? ['payment_methods.agentic', 'userinfo:read'],\n pkce: true,\n accessTokenExpiresIn: 3600,\n tokenEndpointAuth: {\n method: 'custom',\n customizeRequest({ body, headers }) {\n body.set('client_id', options.clientId);\n body.set('client_secret', options.clientSecret);\n headers.authorization = `Bearer ${options.publishableKey}`;\n },\n },\n accountSubject: ({ profile }) => linkProfileSchema.parse(profile).id,\n mapProfileToUser(profile) {\n return {\n name:\n profile.name ||\n [profile.first_name, profile.last_name]\n .filter((part) => typeof part === 'string')\n .join(' ') ||\n 'Link',\n };\n },\n },\n ],\n });\n\n return {\n ...oauth,\n id: 'link',\n endpoints: {\n connectLink: connectLink(),\n disconnectLink: disconnectLink(options),\n },\n $ERROR_CODES: LINK_ERROR_CODES,\n } satisfies BetterAuthPlugin;\n}\n\nexport { LINK_ERROR_CODES } from './error-codes';\n","import { BASE_ERROR_CODES } from 'better-auth';\nimport {\n APIError,\n createAuthEndpoint,\n freshSessionMiddleware,\n linkSocialAccount,\n sensitiveSessionMiddleware,\n} from 'better-auth/api';\nimport { decryptOAuthToken } from 'better-auth/oauth2';\nimport { z } from 'zod';\nimport { LINK_ERROR_CODES } from './error-codes';\nimport type { LinkOptions } from './index';\n\nexport const connectLink = () =>\n createAuthEndpoint(\n '/link/connect',\n {\n method: 'POST',\n requireHeaders: true,\n body: linkSocialAccount.options.body.omit({\n provider: true,\n idToken: true,\n }),\n },\n async (ctx) => {\n const { headers, response } = await linkSocialAccount({\n body: { ...ctx.body, provider: 'link' },\n context: ctx.context,\n headers: ctx.headers,\n ...(ctx.request ? { request: ctx.request } : {}),\n returnHeaders: true,\n });\n\n // Preserve OAuth state cookies and response headers from linkSocialAccount.\n for (const cookie of headers.getSetCookie()) {\n ctx.responseHeaders.append('set-cookie', cookie);\n }\n headers.forEach((value, key) => {\n if (key.toLowerCase() !== 'set-cookie') {\n ctx.responseHeaders.set(key, value);\n }\n });\n\n return ctx.json(response);\n },\n );\n\nexport const disconnectLink = (options: LinkOptions) =>\n createAuthEndpoint(\n '/link/disconnect',\n {\n method: 'POST',\n requireHeaders: true,\n body: z.strictObject({ accountId: z.string().min(1) }),\n use: [sensitiveSessionMiddleware, freshSessionMiddleware],\n },\n async (ctx) => {\n const accounts = await ctx.context.internalAdapter.findAccounts(\n ctx.context.session.user.id,\n );\n const account = accounts.find(\n (candidate) =>\n candidate.id === ctx.body.accountId &&\n candidate.providerId === 'link',\n );\n if (!account) {\n throw APIError.from('BAD_REQUEST', BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);\n }\n if (\n accounts.length === 1 &&\n !ctx.context.options.account?.accountLinking?.allowUnlinkingAll\n ) {\n throw APIError.from(\n 'BAD_REQUEST',\n BASE_ERROR_CODES.FAILED_TO_UNLINK_LAST_ACCOUNT,\n );\n }\n if (!account.refreshToken) {\n throw APIError.from(\n 'BAD_REQUEST',\n LINK_ERROR_CODES.LINK_REFRESH_TOKEN_NOT_FOUND,\n );\n }\n\n let response: Response;\n try {\n const token = await decryptOAuthToken(\n account.refreshToken,\n ctx.context,\n );\n response = await fetch('https://login.link.com/auth/revoke', {\n method: 'POST',\n redirect: 'error',\n signal: AbortSignal.timeout(10_000),\n headers: {\n Authorization: `Bearer ${options.publishableKey}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({\n client_id: options.clientId,\n client_secret: options.clientSecret,\n token,\n token_type_hint: 'refresh_token',\n }),\n });\n } catch {\n throw APIError.from(\n 'BAD_GATEWAY',\n LINK_ERROR_CODES.LINK_REVOCATION_FAILED,\n );\n }\n if (!response.ok) {\n throw APIError.from(\n 'BAD_GATEWAY',\n LINK_ERROR_CODES.LINK_REVOCATION_FAILED,\n );\n }\n\n await ctx.context.internalAdapter.deleteAccount(account.id);\n return ctx.json({ status: true });\n },\n );\n"],"mappings":"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","names":["z","z"]}
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ import type { LinkOptions } from './index';
3
+ export declare const connectLink: () => import("better-auth").StrictEndpoint<"/link/connect", {
4
+ method: "POST";
5
+ requireHeaders: true;
6
+ body: z.ZodObject<{
7
+ callbackURL: z.ZodOptional<z.ZodString>;
8
+ requestSignUp: z.ZodOptional<z.ZodBoolean>;
9
+ scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
10
+ errorCallbackURL: z.ZodOptional<z.ZodString>;
11
+ disableRedirect: z.ZodOptional<z.ZodBoolean>;
12
+ loginHint: z.ZodOptional<z.ZodString>;
13
+ additionalParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
14
+ additionalData: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
15
+ }, z.core.$strip>;
16
+ }, {
17
+ url: string;
18
+ redirect: boolean;
19
+ }>;
20
+ export declare const disconnectLink: (options: LinkOptions) => import("better-auth").StrictEndpoint<"/link/disconnect", {
21
+ method: "POST";
22
+ requireHeaders: true;
23
+ body: z.ZodObject<{
24
+ accountId: z.ZodString;
25
+ }, z.core.$strict>;
26
+ use: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<{
27
+ session: {
28
+ session: Record<string, any> & {
29
+ id: string;
30
+ createdAt: Date;
31
+ updatedAt: Date;
32
+ userId: string;
33
+ expiresAt: Date;
34
+ token: string;
35
+ ipAddress?: string | null | undefined;
36
+ userAgent?: string | null | undefined;
37
+ };
38
+ user: Record<string, any> & {
39
+ id: string;
40
+ createdAt: Date;
41
+ updatedAt: Date;
42
+ email: string;
43
+ emailVerified: boolean;
44
+ name: string;
45
+ image?: string | null | undefined;
46
+ };
47
+ };
48
+ }>>[];
49
+ }, {
50
+ status: boolean;
51
+ }>;
52
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;EAgCrB,CAAC;AAEJ,eAAO,MAAM,cAAc,YAAa,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0EhD,CAAC"}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@stripe/link-integrations-better-auth",
3
+ "version": "0.2.0",
4
+ "description": "Sign in with Link and connect Link wallets using Better Auth",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./client": {
14
+ "types": "./dist/client.d.ts",
15
+ "import": "./dist/client.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "sideEffects": false,
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/stripe/link-cli.git",
28
+ "directory": "packages/integrations/better-auth"
29
+ },
30
+ "homepage": "https://github.com/stripe/link-cli/tree/main/packages/integrations/better-auth#readme",
31
+ "bugs": "https://github.com/stripe/link-cli/issues",
32
+ "engines": {
33
+ "node": ">=22"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "tsup && tsc --noEmit false --emitDeclarationOnly",
40
+ "prepack": "pnpm run build",
41
+ "typecheck": "tsc && tsc -p tsconfig.test.json",
42
+ "test": "vitest run"
43
+ },
44
+ "peerDependencies": {
45
+ "@better-auth/core": "^1.7.5",
46
+ "better-auth": "^1.7.5"
47
+ },
48
+ "dependencies": {
49
+ "zod": "^4.5.4"
50
+ },
51
+ "devDependencies": {
52
+ "@better-auth/core": "1.7.5",
53
+ "@stripe/link-sdk": "workspace:*",
54
+ "@stripe/link-typescript-config": "workspace:*",
55
+ "@types/node": "^26.4.1",
56
+ "better-auth": "1.7.5",
57
+ "tsup": "^8.5.1",
58
+ "typescript": "^7.0.2",
59
+ "vitest": "^5.0.0"
60
+ }
61
+ }