From 0362d62f0d79dce5714b9dac3fc757b4c8aec387 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:59:54 +0000 Subject: [PATCH] test(oauth): cover stateless implicit linking across instances (#11298) --- .../generic-oauth/generic-oauth.test.ts | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts index e488a34111..c84780b819 100644 --- a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts +++ b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts @@ -15,6 +15,7 @@ import { OAuth2Server } from "oauth2-mock-server"; import { afterAll, afterEach, + assert, beforeAll, describe, expect, @@ -27,6 +28,7 @@ import { parseSetCookieHeader } from "../../cookies"; import { symmetricDecodeJWT } from "../../crypto"; import { getOAuthCallbackPath } from "../../oauth2/utils"; import { getTestInstance } from "../../test-utils/test-instance"; +import type { Account } from "../../types"; import { genericOAuth } from "."; import { auth0 } from "./providers/auth0"; import { keycloak } from "./providers/keycloak"; @@ -490,6 +492,119 @@ describe("oauth2", async () => { expect(accessTokenRes.data?.accessToken).toBeTruthy(); }); + /** + * @see https://github.com/better-auth/better-auth/issues/10690 + */ + it("keeps implicit links usable across stateless instances", async () => { + const providerA = "test-implicit-link-a"; + const providerB = "test-implicit-link-b"; + const sharedEmail = "implicit-link-stateless@test.com"; + const options = { + database: undefined, + secret: "stateless-implicit-link-secret-with-32-chars", + session: { + cookieCache: { + enabled: true, + strategy: "jwe" as const, + }, + }, + account: { + storeStateStrategy: "cookie" as const, + storeAccountCookie: true, + }, + plugins: [ + genericOAuth({ + config: [providerA, providerB].map((providerId) => ({ + providerId, + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId, + clientSecret, + pkce: true, + })), + }), + ], + }; + const { + auth: firstAuth, + client, + customFetchImpl: firstFetch, + } = await getTestInstance(options); + const firstContext = await firstAuth.$context; + expect(firstContext.options.database).toBeUndefined(); + + async function completeSignIn(providerId: string, accountId: string) { + server.service.once("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: sharedEmail, + name: "Implicit Link User", + sub: accountId, + email_verified: true, + }; + userInfoResponse.statusCode = 200; + }); + + const stateHeaders = new Headers(); + const signIn = await client.signIn.social({ + provider: providerId, + callbackURL: "http://localhost:3000/dashboard", + newUserCallbackURL: "http://localhost:3000/new-user", + fetchOptions: { onSuccess: cookieSetter(stateHeaders) }, + }); + assert(signIn.data?.url, "expected an OAuth authorization URL"); + const callback = await simulateOAuthFlow( + signIn.data.url, + stateHeaders, + firstFetch, + ); + const session = await client.getSession({ + fetchOptions: { headers: callback.headers }, + }); + assert(session.data, "expected a session after OAuth callback"); + return { ...callback, userId: session.data.user.id }; + } + + const first = await completeSignIn(providerA, "implicit-link-account-a"); + expect(first.callbackURL).toBe("http://localhost:3000/new-user"); + const second = await completeSignIn(providerB, "implicit-link-account-b"); + expect(second.callbackURL).toBe("http://localhost:3000/dashboard"); + expect(second.userId).toBe(first.userId); + + const accountCookieName = firstContext.authCookies.accountData.name; + const accountCookie = parseSetCookieHeader(second.setCookieHeader).get( + accountCookieName, + ); + assert(accountCookie?.value, "implicit link must set an account cookie"); + const linkedAccount = await symmetricDecodeJWT( + accountCookie.value, + firstContext.secret, + "better-auth-account", + ); + assert( + linkedAccount?.accessToken, + "linked account cookie must carry a token", + ); + expect(linkedAccount).toMatchObject({ + providerId: providerB, + accountId: "implicit-link-account-b", + userId: first.userId, + }); + + const { auth: secondAuth, client: secondClient } = + await getTestInstance(options); + const secondContext = await secondAuth.$context; + expect(secondContext.options.database).toBeUndefined(); + expect( + await secondContext.internalAdapter.findAccounts(first.userId), + ).toEqual([]); + + const token = await secondClient.getAccessToken( + { useAccountCookie: true }, + { headers: second.headers }, + ); + expect(token.error).toBeNull(); + expect(token.data?.accessToken).toBe(linkedAccount.accessToken); + }); + /** * A provider that omits `expires_in` leaves the access token's expiry unknown, * so `getAccessToken` can never tell it lapsed and never refreshes it. Setting