mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
fix(polar)!: match OAuth userinfo profile data
This commit is contained in:
@@ -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 {};
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user