mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-26 22:42:35 -05:00
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)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user