fix(expo): send origin for id token link social (#10207)

Co-authored-by: Taesu <bytaesu@gmail.com>
This commit is contained in:
Nooc
2026-10-17 07:41:40 +09:00
committed by GitHub
co-authored by Taesu
parent 6c48290c99
commit 5d3d5c4c70
3 changed files with 137 additions and 15 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@better-auth/expo": patch
---
Send the Expo origin header when linking social accounts with an ID token so native link flows that include a session cookie pass origin validation.
+6 -10
View File
@@ -455,25 +455,21 @@ export const expoClient = (opts: ExpoClientOptions) => {
}
options = options || {};
options.credentials = "omit";
/**
* ID token flow (native sign-in) doesn't need cookie-based auth.
* The ID token itself is cryptographically signed by the provider
* and validated server-side, so no session cookies or origin
* validation is required.
*
* Sending cookie/expo-origin headers for ID token requests triggers
* unnecessary origin checks that fail for custom URL schemes.
*/
const isIdTokenRequest = options.body?.idToken !== undefined;
if (isIdTokenRequest) {
const storedCookie = pathname.endsWith("/link-social")
const isLinkSocial = pathname.endsWith("/link-social");
// ID token sign-in validates the provider token directly. Link-social
// still needs the current session cookie, so it also needs an origin
// header for the server-side CSRF check.
const storedCookie = isLinkSocial
? await storage.getItemAsync(cookieName)
: null;
const cookie = getCookie(storedCookie);
options.headers = {
...options.headers,
...(cookie ? { cookie } : {}),
...(isLinkSocial ? { "expo-origin": getOrigin(scheme!) } : {}),
"x-skip-oauth-proxy": "true",
};
} else {
+126 -5
View File
@@ -113,6 +113,12 @@ describe("expo", async () => {
});
it("should send cookie and get session", async () => {
const signIn = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(signIn.error).toBeNull();
const { data } = await client.getSession();
expect(data).toMatchObject({
session: expect.any(Object),
@@ -548,9 +554,9 @@ describe("expo", async () => {
});
it("should not send cookie or expo-origin headers for ID token requests", async () => {
let cookieHeader: string | null | undefined = null;
let expoOriginHeader: string | null | undefined = null;
let originHeader: string | null | undefined = null;
let cookieHeader: string | null | undefined;
let expoOriginHeader: string | null | undefined;
let originHeader: string | null | undefined;
const storage = new Map<string, string>();
// Pre-populate storage with a cookie to verify it's NOT sent
@@ -615,7 +621,7 @@ describe("expo", async () => {
/**
* @see https://github.com/better-auth/better-auth/issues/9900
*/
it("should send cookie for link-social ID token requests", async () => {
it("should send cookie and expo-origin for link-social ID token requests", async () => {
let cookieHeader: string | null | undefined = null;
let expoOriginHeader: string | null | undefined = null;
const storage = new Map<string, string>();
@@ -672,9 +678,124 @@ describe("expo", async () => {
});
expect(cookieHeader).toContain("better-auth.session_token=existing-token");
expect(expoOriginHeader).toBeNull();
expect(expoOriginHeader).toBe("better-auth://");
});
/**
* @see https://github.com/better-auth/better-auth/issues/9900
*/
it.each([
{
trust: "trusted",
trustedOrigins: ["better-auth://"],
expectedError: null,
expectedData: expect.objectContaining({ status: true, redirect: false }),
expectedAccounts: [
expect.objectContaining({
providerId: "google",
accountId: "google-user",
}),
],
},
{
trust: "untrusted",
trustedOrigins: [],
expectedError: expect.objectContaining({
status: 403,
code: "INVALID_ORIGIN",
}),
expectedData: null,
expectedAccounts: [],
},
])(
"should enforce origin validation for link-social ID token requests with $trust origins",
async ({
trustedOrigins,
expectedError,
expectedData,
expectedAccounts,
}) => {
const storage = new Map<string, string>();
const profile = {
sub: "google-user",
name: "Expo User",
email: "expo-linking@example.com",
email_verified: true,
aud: "test",
azp: "test",
iss: "https://accounts.google.com",
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
given_name: "Expo",
family_name: "User",
picture: "https://example.com/avatar.png",
};
const { client, testUser } = await getTestInstance(
{
plugins: [expo()],
trustedOrigins,
advanced: {
disableOriginCheck: false,
disableCSRFCheck: false,
},
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
verifyIdToken: async (token) => token === "valid-id-token",
getUserInfo: async () => ({
user: {
name: profile.name,
email: profile.email,
emailVerified: profile.email_verified,
},
data: profile,
}),
},
},
},
{
testUser: { email: profile.email },
clientOptions: {
plugins: [
expoClient({
storage: {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => {
storage.set(key, value);
},
getItemAsync: async (key) => storage.get(key) ?? null,
setItemAsync: async (key, value) => {
storage.set(key, value);
},
},
}),
],
},
},
);
const signIn = await client.signIn.email(
{ email: testUser.email, password: testUser.password },
{ headers: { origin: "http://localhost:3000" } },
);
expect(signIn.error).toBeNull();
const linked = await client.linkSocial({
provider: "google",
idToken: { token: "valid-id-token" },
});
expect(linked.error).toEqual(expectedError);
expect(linked.data).toEqual(expectedData);
const accounts = await client.listAccounts();
expect(accounts.error).toBeNull();
expect(
accounts.data?.filter((account) => account.providerId === "google"),
).toEqual(expectedAccounts);
},
);
it("should preserve existing cookies on link-social", async () => {
await client.signIn.email({
email: testUser.email,