From 3fbc2f3405037efce76a7e32ced63b89f39be09d Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Sat, 30 May 2026 23:27:18 +0100 Subject: [PATCH] test(generic-oauth): port regression suites onto the social-provider rewrite (#9799, #9792, #9702, #9578) next's #9069 rewrote generic-oauth to flow through the core social provider and callback. The #9799 accessTokenExpiresIn source fix merged in cleanly (its config field and the applyDefaultAccessTokenExpiry wiring land on the three token paths); main's regression tests still targeted the old signIn.oauth2 plugin API. This ports them onto signIn.social so they keep guarding the behavior: - #9799: getAccessToken refreshes once the accessTokenExpiresIn window passes when the provider omits expires_in, does not refresh when it is unset, and applies it to a custom getToken result. - #9792: the verify-email link preserves the encoded callbackURL. - #9702: a session hook throwing an APIError redirects to the cross-origin errorCallbackURL with the hook's code (proves the rewritten callback's hook-error forwarding). - #9578: implicit-link tests mark the local user verified so linking is allowed under the default-on requireLocalEmailVerified gate. A stale IDP-bounce assertion is updated to next's state-error vocabulary. All 71 tests pass. (cherry picked from commit 52adab0db3f463295a0c4e3152d770f4ce40b2d2) --- .../generic-oauth/generic-oauth.test.ts | 425 +++++++++++++++++- 1 file changed, 423 insertions(+), 2 deletions(-) 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 8e995189f7..6b3375bdb9 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 @@ -1,7 +1,12 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { runWithEndpointContext } from "@better-auth/core/context"; +import { APIError } from "@better-auth/core/error"; import { betterFetch } from "@better-fetch/fetch"; -import { OAuth2Server } from "oauth2-mock-server"; +import { + OAuth2Server, + type MutableResponse, + type TokenRequestIncomingMessage, +} from "oauth2-mock-server"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createAuthClient } from "../../client"; import { getAwaitableValue } from "../../context/helpers"; @@ -28,6 +33,15 @@ describe("oauth2", async () => { }); const { customFetchImpl, auth, cookieSetter } = await getTestInstance({ + databaseHooks: { + user: { + create: { + before: async (user) => ({ + data: { ...user, emailVerified: true }, + }), + }, + }, + }, plugins: [ genericOAuth({ config: [ @@ -224,6 +238,68 @@ describe("oauth2", async () => { }); }); + /** + * The verify-email link sent to a new, unverified OAuth user must keep the + * caller's `callbackURL` intact. A raw interpolation truncates any value + * containing `&` at the first ampersand. + * + * @see https://github.com/better-auth/better-auth/issues/6086 + */ + it("encodes callbackURL in the verify-email link for a new unverified OAuth user", async () => { + let capturedUrl = ""; + const { customFetchImpl: localFetch } = await getTestInstance({ + emailVerification: { + sendOnSignUp: true, + async sendVerificationEmail({ url }) { + capturedUrl = url; + }, + }, + plugins: [ + genericOAuth({ + config: [ + { + providerId: "test-encode-callback", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId, + clientSecret, + pkce: true, + }, + ], + }), + ], + }); + const localClient = createAuthClient({ + baseURL: "http://localhost:3000", + fetchOptions: { customFetchImpl: localFetch }, + }); + + server.service.once("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: "encode-callback@test.com", + name: "Encode Callback", + sub: "encode-callback", + email_verified: false, + }; + userInfoResponse.statusCode = 200; + }); + + const callbackURL = "http://localhost:3000/welcome?ref=oauth&plan=pro"; + const headers = new Headers(); + const signInRes = await localClient.signIn.social({ + provider: "test-encode-callback", + callbackURL, + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + await simulateOAuthFlow(signInRes.data?.url || "", headers, localFetch); + + expect(capturedUrl).not.toBe(""); + expect(new URL(capturedUrl).searchParams.get("callbackURL")).toBe( + callbackURL, + ); + }); + /** * @see https://github.com/better-auth/better-auth/issues/9375 */ @@ -324,6 +400,272 @@ describe("oauth2", async () => { expect(accessTokenRes.data?.accessToken).toBeTruthy(); }); + /** + * 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 + * `accessTokenExpiresIn` synthesizes the expiry so the existing refresh path + * runs once the window passes. + * + * @see https://github.com/better-auth/better-auth/issues/7703 + */ + it("refreshes once the accessTokenExpiresIn window passes when the provider omits expires_in", async () => { + let refreshCount = 0; + const omitExpiresIn = ( + response: MutableResponse, + req: TokenRequestIncomingMessage, + ) => { + const body = response.body; + if (body && typeof body === "object" && "access_token" in body) { + // Simulate a provider that never returns `expires_in`. + body.expires_in = undefined; + if (req.body?.grant_type === "refresh_token") refreshCount++; + } + }; + server.service.on("beforeResponse", omitExpiresIn); + server.service.once("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: "exp-fallback@test.com", + name: "Expires In Fallback", + sub: "exp-fallback", + email_verified: true, + }; + userInfoResponse.statusCode = 200; + }); + + try { + const { customFetchImpl } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "exp-fallback", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId, + clientSecret, + pkce: true, + accessTokenExpiresIn: 3600, + }, + ], + }), + ], + }); + const client = createAuthClient({ + baseURL: "http://localhost:3000", + fetchOptions: { customFetchImpl }, + }); + + const headers = new Headers(); + const signInRes = await client.signIn.social({ + provider: "exp-fallback", + callbackURL: "http://localhost:3000/dashboard", + newUserCallbackURL: "http://localhost:3000/new_user", + fetchOptions: { onSuccess: cookieSetter(headers) }, + }); + const { headers: postCallbackHeaders } = await simulateOAuthFlow( + signInRes.data?.url || "", + headers, + customFetchImpl, + ); + + // Within the synthesized window: no premature refresh. + const fresh = await client.getAccessToken( + { providerId: "exp-fallback" }, + { headers: postCallbackHeaders }, + ); + expect(fresh.data?.accessToken).toBeTruthy(); + expect(refreshCount).toBe(0); + + // Past the synthesized expiry: the refresh that never used to fire now does. + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date(Date.now() + 2 * 60 * 60 * 1000)); + const refreshed = await client.getAccessToken( + { providerId: "exp-fallback" }, + { headers: postCallbackHeaders }, + ); + expect(refreshed.data?.accessToken).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + expect(refreshCount).toBe(1); + } finally { + server.service.off("beforeResponse", omitExpiresIn); + } + }); + + /** + * Opt-in: without `accessTokenExpiresIn`, a provider that omits `expires_in` + * keeps the prior behavior. The expiry stays unknown and `getAccessToken` does + * not refresh, so nothing churns for tokens that may never expire. + * + * @see https://github.com/better-auth/better-auth/issues/7703 + */ + it("does not refresh a provider that omits expires_in when accessTokenExpiresIn is unset", async () => { + let refreshCount = 0; + const omitExpiresIn = ( + response: MutableResponse, + req: TokenRequestIncomingMessage, + ) => { + const body = response.body; + if (body && typeof body === "object" && "access_token" in body) { + body.expires_in = undefined; + if (req.body?.grant_type === "refresh_token") refreshCount++; + } + }; + server.service.on("beforeResponse", omitExpiresIn); + server.service.once("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: "exp-none@test.com", + name: "No Fallback", + sub: "exp-none", + email_verified: true, + }; + userInfoResponse.statusCode = 200; + }); + + try { + const { customFetchImpl } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "exp-none", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId, + clientSecret, + pkce: true, + }, + ], + }), + ], + }); + const client = createAuthClient({ + baseURL: "http://localhost:3000", + fetchOptions: { customFetchImpl }, + }); + + const headers = new Headers(); + const signInRes = await client.signIn.social({ + provider: "exp-none", + callbackURL: "http://localhost:3000/dashboard", + newUserCallbackURL: "http://localhost:3000/new_user", + fetchOptions: { onSuccess: cookieSetter(headers) }, + }); + const { headers: postCallbackHeaders } = await simulateOAuthFlow( + signInRes.data?.url || "", + headers, + customFetchImpl, + ); + + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date(Date.now() + 2 * 60 * 60 * 1000)); + const res = await client.getAccessToken( + { providerId: "exp-none" }, + { headers: postCallbackHeaders }, + ); + // Expiry stays unknown: the stored token is returned as-is, no refresh. + expect(res.data?.accessToken).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + expect(refreshCount).toBe(0); + } finally { + server.service.off("beforeResponse", omitExpiresIn); + } + }); + + /** + * The `getToken` escape hatch returns tokens directly, bypassing the standard + * exchange. `accessTokenExpiresIn` must still apply to its result, or a + * `getToken` provider that omits the expiry hits the same no-refresh bug. + * + * @see https://github.com/better-auth/better-auth/issues/7703 + */ + it("applies accessTokenExpiresIn to a custom getToken result", async () => { + let refreshCount = 0; + const countRefresh = ( + _response: MutableResponse, + req: TokenRequestIncomingMessage, + ) => { + if (req.body?.grant_type === "refresh_token") refreshCount++; + }; + server.service.on("beforeResponse", countRefresh); + + try { + const { customFetchImpl } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "exp-gettoken", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId, + clientSecret, + pkce: true, + accessTokenExpiresIn: 3600, + // Custom exchange that omits the expiry. + getToken: async () => ({ + accessToken: "gettoken-initial", + refreshToken: "gettoken-refresh", + }), + getUserInfo: async () => ({ + id: "exp-gettoken-user", + email: "exp-gettoken@test.com", + name: "GetToken User", + emailVerified: true, + }), + }, + ], + }), + ], + }); + const client = createAuthClient({ + baseURL: "http://localhost:3000", + fetchOptions: { customFetchImpl }, + }); + + const headers = new Headers(); + const signInRes = await client.signIn.social({ + provider: "exp-gettoken", + callbackURL: "http://localhost:3000/dashboard", + newUserCallbackURL: "http://localhost:3000/new_user", + fetchOptions: { onSuccess: cookieSetter(headers) }, + }); + const { headers: postCallbackHeaders } = await simulateOAuthFlow( + signInRes.data?.url || "", + headers, + customFetchImpl, + ); + + // getToken omitted the expiry; the fallback synthesized it, so within + // the window the stored token is returned without refreshing. + const fresh = await client.getAccessToken( + { providerId: "exp-gettoken" }, + { headers: postCallbackHeaders }, + ); + expect(fresh.data?.accessToken).toBe("gettoken-initial"); + expect(refreshCount).toBe(0); + + // Past the synthesized expiry: refresh fires. Without the fallback on the + // getToken result, no expiry would be stored and this never happens. + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date(Date.now() + 2 * 60 * 60 * 1000)); + const refreshed = await client.getAccessToken( + { providerId: "exp-gettoken" }, + { headers: postCallbackHeaders }, + ); + expect(refreshed.data?.accessToken).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + expect(refreshCount).toBe(1); + } finally { + server.service.off("beforeResponse", countRefresh); + } + }); + it("should redirect to the provider and handle the response after linked", async () => { const headers = new Headers(); const res = await authClient.signIn.social({ @@ -544,6 +886,84 @@ describe("oauth2", async () => { expect(callbackURL).toBe("http://localhost:3000/dashboard"); }); + /** + * @see https://github.com/better-auth/better-auth/issues/9702 + */ + it("should redirect to cross-origin errorCallbackURL when a session hook throws APIError", async () => { + server.service.once("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: "hook-reject@test.com", + name: "Hook Reject User", + sub: "hook-reject", + picture: "https://test.com/picture.png", + email_verified: true, + }; + userInfoResponse.statusCode = 200; + }); + + const { customFetchImpl, cookieSetter } = await getTestInstance( + { + trustedOrigins: ["https://frontend.example.com"], + plugins: [ + genericOAuth({ + config: [ + { + providerId: "test-hook-reject", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId, + clientSecret, + pkce: true, + }, + ], + }), + ], + databaseHooks: { + user: { + create: { + before: async (user) => ({ + data: { ...user, emailVerified: true }, + }), + }, + }, + session: { + create: { + before: async () => { + throw APIError.from("FORBIDDEN", { + code: "HOOK_REJECTED", + message: "Session hook rejected this user", + }); + }, + }, + }, + }, + }, + { disableTestUser: true }, + ); + const authClient = createAuthClient({ + baseURL: "http://localhost:3000", + fetchOptions: { customFetchImpl }, + }); + const headers = new Headers(); + const res = await authClient.signIn.social({ + provider: "test-hook-reject", + callbackURL: "https://frontend.example.com/dashboard", + errorCallbackURL: "https://frontend.example.com/auth-error", + fetchOptions: { onSuccess: cookieSetter(headers) }, + }); + const { callbackURL } = await simulateOAuthFlow( + res.data?.url || "", + headers, + customFetchImpl, + ); + const url = new URL(callbackURL); + expect(url.origin).toBe("https://frontend.example.com"); + expect(url.pathname).toBe("/auth-error"); + expect(url.searchParams.get("error")).toBe("HOOK_REJECTED"); + expect(url.searchParams.get("error_description")).toBe( + "Session hook rejected this user", + ); + }); + it("should pass authorization headers in oAuth2Callback", async () => { const customHeaders = { "X-Custom-Header": "test-value", @@ -2481,7 +2901,8 @@ describe("oauth2", async () => { expect(res.status).toBe(302); const location = res.headers.get("location") || ""; expect(location).not.toContain(`http://localhost:${port}/authorize`); - expect(location).toContain("please_restart_the_process"); + expect(location).toContain("error=state_mismatch"); + expect(location).not.toContain("please_restart_the_process"); }); }); });