diff --git a/.changeset/gentle-polar-claims.md b/.changeset/gentle-polar-claims.md new file mode 100644 index 0000000000..18cfb89448 --- /dev/null +++ b/.changeset/gentle-polar-claims.md @@ -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. diff --git a/packages/core/src/social-providers/polar.test.ts b/packages/core/src/social-providers/polar.test.ts new file mode 100644 index 0000000000..0e4ad6f1f7 --- /dev/null +++ b/packages/core/src/social-providers/polar.test.ts @@ -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(); + expectTypeOf(profile.name).toEqualTypeOf(); + expectTypeOf(profile.email).toEqualTypeOf(); + expectTypeOf(profile.email_verified).toEqualTypeOf< + boolean | null | undefined + >(); + return {}; + }, + }); + }); +}); diff --git a/packages/core/src/social-providers/polar.ts b/packages/core/src/social-providers/polar.ts index 0892057461..1ae9dcdfb6 100644 --- a/packages/core/src/social-providers/polar.ts +++ b/packages/core/src/social-providers/polar.ts @@ -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 {} @@ -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, },