fix(polar)!: match OAuth userinfo profile data

This commit is contained in:
Taesu
2026-10-16 09:58:59 +09:00
parent 2c7833fad6
commit 91f784832b
3 changed files with 126 additions and 24 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@better-auth/core": minor
---
Identify Polar accounts by their userinfo subject and expose the scoped, nullable userinfo claims in `mapProfileToUser` instead of unrelated user profile fields.
@@ -0,0 +1,114 @@
import { betterFetch } from "@better-fetch/fetch";
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
import type { PolarProfile } from "./polar";
import { polar } from "./polar";
vi.mock(import("@better-fetch/fetch"), () => ({
betterFetch: vi.fn(),
}));
const mockedBetterFetch = vi.mocked(betterFetch);
const options = { clientId: "polar-client", clientSecret: "polar-secret" };
/**
* @see https://github.com/polarsource/polar/blob/main/server/polar/oauth2/userinfo.py
* @see https://github.com/polarsource/polar/blob/main/server/polar/oauth2/schemas.py
*/
describe("Polar userinfo profiles", () => {
beforeEach(() => {
mockedBetterFetch.mockReset();
});
it("uses the userinfo subject for account identity", async () => {
const profile = {
sub: "polar-user",
email: "user@example.com",
email_verified: true,
} satisfies PolarProfile;
mockedBetterFetch.mockResolvedValueOnce({ data: profile, error: null });
const provider = polar(options);
const tokens = { accessToken: "polar-access-token" };
const result = await provider.getUserInfo(tokens);
expect(result?.user.email).toBe("user@example.com");
expect(result?.user.emailVerified).toBe(true);
expect(result?.data).toEqual(profile);
expect(provider.accountSubject({ tokens, profile })).toBe("polar-user");
});
it("maps an organization name without an email", async () => {
const profile = {
sub: "polar-organization",
name: "organization-slug",
} satisfies PolarProfile;
mockedBetterFetch.mockResolvedValueOnce({ data: profile, error: null });
const result = await polar(options).getUserInfo({ accessToken: "token" });
expect(result?.user.name).toBe("organization-slug");
expect(result?.user.email).toBeUndefined();
expect(result?.user.emailVerified).toBe(false);
expect(result?.data).toEqual(profile);
});
it("lets profile mapping supply an email for a subject-only response", async () => {
const profile = { sub: "polar-user" } satisfies PolarProfile;
mockedBetterFetch.mockResolvedValueOnce({ data: profile, error: null });
const mapProfileToUser = vi.fn((claims: PolarProfile) => ({
name: "Mapped User",
email: `${claims.sub}@example.com`,
}));
const result = await polar({ ...options, mapProfileToUser }).getUserInfo({
accessToken: "token",
});
expect(mapProfileToUser).toHaveBeenCalledWith(profile);
expect(result?.user).toMatchObject({
name: "Mapped User",
email: "polar-user@example.com",
emailVerified: false,
});
expect(result?.data).toEqual(profile);
});
it("preserves nullable claims and profile mapping overrides", async () => {
const profile = {
sub: "polar-user",
name: null,
email: null,
email_verified: null,
} satisfies PolarProfile;
mockedBetterFetch.mockResolvedValueOnce({ data: profile, error: null });
const mapProfileToUser = vi.fn(() => ({
name: "Mapped User",
email: "mapped@example.com",
image: "https://example.com/avatar.png",
}));
const result = await polar({ ...options, mapProfileToUser }).getUserInfo({
accessToken: "token",
});
expect(mapProfileToUser).toHaveBeenCalledWith(profile);
expect(result?.data).toEqual(profile);
expect(result?.user).toEqual({
name: "Mapped User",
email: "mapped@example.com",
image: "https://example.com/avatar.png",
emailVerified: false,
});
});
it("exposes nullable optional claims in profile mapping", () => {
polar({
...options,
mapProfileToUser(profile) {
expectTypeOf(profile.sub).toEqualTypeOf<string>();
expectTypeOf(profile.name).toEqualTypeOf<string | null | undefined>();
expectTypeOf(profile.email).toEqualTypeOf<string | null | undefined>();
expectTypeOf(profile.email_verified).toEqualTypeOf<
boolean | null | undefined
>();
return {};
},
});
});
});
+7 -24
View File
@@ -6,28 +6,12 @@ import {
validateAuthorizationCode,
} from "../oauth2";
/** Claims returned by Polar's OAuth userinfo endpoint for a user or organization. */
export interface PolarProfile {
id: string;
email: string;
username: string;
avatar_url: string;
github_username?: string | undefined;
account_id?: string | undefined;
public_name?: string | undefined;
email_verified?: boolean | undefined;
profile_settings?:
| {
profile_settings_enabled?: boolean;
profile_settings_public_name?: string;
profile_settings_public_avatar?: string;
profile_settings_public_bio?: string;
profile_settings_public_location?: string;
profile_settings_public_website?: string;
profile_settings_public_twitter?: string;
profile_settings_public_github?: string;
profile_settings_public_email?: string;
}
| undefined;
sub: string;
name?: string | null | undefined;
email?: string | null | undefined;
email_verified?: boolean | null | undefined;
}
export interface PolarOptions extends ProviderOptions<PolarProfile> {}
@@ -37,7 +21,7 @@ export const polar = (options: PolarOptions) => {
return {
id: "polar",
name: "Polar",
accountSubject: ({ profile }) => profile.id,
accountSubject: ({ profile }) => profile.sub,
createAuthorizationURL({
state,
scopes,
@@ -104,9 +88,8 @@ export const polar = (options: PolarOptions) => {
// We check for it first, then default to false for security consistency.
return {
user: {
name: profile.public_name || profile.username || "",
name: profile.name ?? "",
email: profile.email,
image: profile.avatar_url,
emailVerified: profile.email_verified ?? false,
...userMap,
},