mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-24 16:11:53 -05:00
Fix legacy OAuth state compatibility
Signed-off-by: Gautam Manchandani <manchandanigautam@gmail.com>
This commit is contained in:
committed by
Gustavo Valverde
parent
caf9b8a764
commit
04f0905b4b
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user