mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
fix(expo): send origin for id token link social (#10207)
Co-authored-by: Taesu <bytaesu@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user