Fix legacy OAuth state compatibility

Signed-off-by: Gautam Manchandani <manchandanigautam@gmail.com>
This commit is contained in:
Gautam Manchandani
2026-03-22 16:29:49 +05:30
committed by Gustavo Valverde
parent caf9b8a764
commit 04f0905b4b
4 changed files with 126 additions and 20 deletions

View File

@@ -1153,6 +1153,92 @@ describe("oauth2", async () => {
expect(session.data?.user.email).toBe("oauth2-overridden-state@test.com");
});
it("should accept legacy raw oauth-state verification identifiers during callback", async () => {
server.service.once("beforeUserinfo", (userInfoResponse) => {
userInfoResponse.body = {
email: "oauth2-legacy-state@test.com",
name: "OAuth2 Legacy State",
sub: "oauth2-legacy-state",
picture: "https://test.com/picture.png",
email_verified: true,
};
userInfoResponse.statusCode = 200;
});
const { customFetchImpl, auth, cookieSetter } = await getTestInstance({
verification: {
storeIdentifier: "hashed",
},
plugins: [
genericOAuth({
config: [
{
providerId: "test-legacy-state",
discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`,
clientId: clientId,
clientSecret: clientSecret,
pkce: true,
},
],
}),
],
});
const headers = new Headers();
const authClient = createAuthClient({
plugins: [genericOAuthClient()],
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
onSuccess: cookieSetter(headers),
},
});
const res = await authClient.signIn.oauth2({
providerId: "test-legacy-state",
callbackURL: "http://localhost:3000/dashboard",
newUserCallbackURL: "http://localhost:3000/new_user",
fetchOptions: {
onSuccess: cookieSetter(headers),
},
});
expect(res.data?.url).toContain(`http://localhost:${port}/authorize`);
const state = new URL(res.data?.url || "").searchParams.get("state");
expect(state).toBeTruthy();
const ctx = await auth.$context;
const prefixed = await ctx.internalAdapter.findVerificationValue(
`oauth-state:${state}`,
);
expect(prefixed).not.toBeNull();
await ctx.internalAdapter.createVerificationValue({
identifier: state!,
value: prefixed!.value,
expiresAt: prefixed!.expiresAt,
});
await ctx.internalAdapter.deleteVerificationByIdentifier(
`oauth-state:${state}`,
);
const { callbackURL, headers: newHeaders } = await simulateOAuthFlow(
res.data?.url || "",
headers,
customFetchImpl,
);
expect(callbackURL).toBe("http://localhost:3000/new_user");
const session = await authClient.getSession({
fetchOptions: {
headers: newHeaders,
},
});
expect(session.data).not.toBeNull();
expect(session.data?.user.email).toBe("oauth2-legacy-state@test.com");
expect(await ctx.internalAdapter.findVerificationValue(state!)).toBeNull();
});
it("should await async mapProfileToUser", async () => {
const { auth } = await getTestInstance({
plugins: [

View File

@@ -17,7 +17,7 @@ import { parseSetCookieHeader } from "../../cookies/cookie-utils";
import { symmetricDecrypt, symmetricEncrypt } from "../../crypto";
import { handleOAuthUserInfo } from "../../oauth2/link-account";
import type { StateData } from "../../state";
import { parseGenericState } from "../../state";
import { findStoredOAuthState, parseGenericState } from "../../state";
import type { Account, User } from "../../types";
import { getOrigin } from "../../utils/url";
import { PACKAGE_VERSION } from "../../version";
@@ -526,15 +526,15 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
}
} else {
// Database mode - read from DB
const verification =
await ctx.context.internalAdapter.findVerificationValue(
originalState,
);
if (verification) {
const storedState = await findStoredOAuthState(
ctx,
originalState,
);
if (storedState) {
// Encrypt the verification value so it matches cookie mode format
stateCookieValue = await symmetricEncrypt({
key: getEncryptionKey(ctx),
data: verification.value,
data: storedState.data.value,
});
}
}

View File

@@ -31,6 +31,26 @@ function getOAuthStateIdentifier(state: string) {
return `${OAUTH_STATE_IDENTIFIER_PREFIX}${state}`;
}
export async function findStoredOAuthState(
c: GenericEndpointContext,
state: string,
) {
const identifier = getOAuthStateIdentifier(state);
const data =
await c.context.internalAdapter.findVerificationValue(identifier);
if (data) {
return { data, identifier };
}
const legacyData =
await c.context.internalAdapter.findVerificationValue(state);
if (legacyData) {
return { data: legacyData, identifier: state };
}
return null;
}
export type StateErrorCode =
| "state_generation_error"
| "state_invalid"
@@ -170,18 +190,15 @@ export async function parseGenericState(
expireCookie(c, stateCookie);
} else {
// Default: database strategy
const verificationIdentifier = getOAuthStateIdentifier(state);
const data = await c.context.internalAdapter.findVerificationValue(
verificationIdentifier,
);
if (!data) {
const storedState = await findStoredOAuthState(c, state);
if (!storedState) {
throw new StateError("State mismatch: verification not found", {
code: "state_mismatch",
details: { state },
});
}
parsedData = stateDataSchema.parse(JSON.parse(data.value));
parsedData = stateDataSchema.parse(JSON.parse(storedState.data.value));
const stateCookie = c.context.createAuthCookie(
settings?.cookieName ?? "state",
@@ -218,7 +235,7 @@ export async function parseGenericState(
// Delete verification value after retrieval
await c.context.internalAdapter.deleteVerificationByIdentifier(
verificationIdentifier,
storedState.identifier,
);
}

View File

@@ -113,13 +113,16 @@ describe("expo", async () => {
provider: "google",
callbackURL: "/dashboard",
});
const stateId = res?.url?.split("state=")[1]!.split("&")[0];
const ctx = await auth.$context;
if (!stateId) {
throw new Error("State ID not found");
}
const state = await ctx.internalAdapter.findVerificationValue(stateId);
const callbackURL = JSON.parse(state?.value || "{}").callbackURL;
const states = await ctx.adapter.findMany<{ value: string }>({
model: "verification",
sortBy: {
field: "createdAt",
direction: "desc",
},
limit: 1,
});
const callbackURL = JSON.parse(states[0]?.value || "{}").callbackURL;
expect(callbackURL).toBe("better-auth:///dashboard");
expect(res).toMatchObject({
url: expect.stringContaining("accounts.google"),