diff --git a/.changeset/bright-roblox-profile.md b/.changeset/bright-roblox-profile.md new file mode 100644 index 0000000000..7e02b0136b --- /dev/null +++ b/.changeset/bright-roblox-profile.md @@ -0,0 +1,5 @@ +--- +"@better-auth/core": minor +--- + +Correct Roblox profile types for optional scoped claims and nullable pictures, and normalize missing local images. diff --git a/packages/core/src/social-providers/roblox.test.ts b/packages/core/src/social-providers/roblox.test.ts index 5d39902005..98cc635870 100644 --- a/packages/core/src/social-providers/roblox.test.ts +++ b/packages/core/src/social-providers/roblox.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, expectTypeOf, it, vi } from "vitest"; vi.mock(import("@better-fetch/fetch"), () => ({ betterFetch: vi.fn(), })); import { betterFetch } from "@better-fetch/fetch"; - +import type { RobloxProfile } from "./roblox"; import { roblox } from "./roblox"; const mockedBetterFetch = vi.mocked(betterFetch); @@ -33,3 +33,75 @@ describe("roblox.getUserInfo", () => { expect(result?.user.email).toBe("roblox-user-1@roblox.placeholder.invalid"); }); }); + +/** + * @see https://create.roblox.com/docs/cloud/auth/oauth2-reference + */ +describe("Roblox userinfo claims", () => { + it("accepts a subject-only response without the profile scope", async () => { + const profile = { sub: "roblox-user-1" } satisfies RobloxProfile; + mockedBetterFetch.mockResolvedValue({ data: profile, error: null }); + const provider = roblox({ + clientId: "roblox-app", + clientSecret: "roblox-secret", + disableDefaultScope: true, + scope: ["openid"], + mapProfileToUser(rawProfile) { + expectTypeOf(rawProfile.sub).toEqualTypeOf(); + expectTypeOf(rawProfile.preferred_username).toEqualTypeOf< + string | undefined + >(); + expectTypeOf(rawProfile.nickname).toEqualTypeOf(); + expectTypeOf(rawProfile.name).toEqualTypeOf(); + expectTypeOf(rawProfile.created_at).toEqualTypeOf(); + expectTypeOf(rawProfile.profile).toEqualTypeOf(); + expectTypeOf(rawProfile.picture).toEqualTypeOf< + string | null | undefined + >(); + expectTypeOf(rawProfile.custom_claim).toEqualTypeOf(); + return {}; + }, + }); + const result = await provider.getUserInfo({ accessToken: "access-token" }); + + expect(result?.user).toEqual({ + name: "", + image: undefined, + email: "roblox-user-1@roblox.placeholder.invalid", + emailVerified: false, + }); + expect(result?.data).toEqual(profile); + expect( + provider.accountSubject({ + profile, + tokens: { accessToken: "access-token" }, + }), + ).toBe(profile.sub); + }); + + it.each([null, "https://example.com/avatar.png"])( + "preserves the raw picture %s while normalizing the local image", + async (picture) => { + const profile = { + sub: "roblox-user-1", + nickname: "Builderman", + picture, + } satisfies RobloxProfile; + mockedBetterFetch.mockResolvedValue({ data: profile, error: null }); + const mapProfileToUser = vi.fn((rawProfile: RobloxProfile) => { + expect(rawProfile).toEqual(profile); + return {}; + }); + const result = await roblox({ + clientId: "roblox-app", + clientSecret: "roblox-secret", + mapProfileToUser, + }).getUserInfo({ accessToken: "access-token" }); + + expect(result?.user.image).toBe(picture ?? undefined); + expect(result?.user.name).toBe("Builderman"); + expect(result?.data.picture).toBe(picture); + expect(mapProfileToUser).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/packages/core/src/social-providers/roblox.ts b/packages/core/src/social-providers/roblox.ts index dfded6a68c..6bf77f38ef 100644 --- a/packages/core/src/social-providers/roblox.ts +++ b/packages/core/src/social-providers/roblox.ts @@ -7,21 +7,21 @@ import { } from "../oauth2"; import { createPlaceholderEmail } from "../utils/email"; -export interface RobloxProfile extends Record { +export interface RobloxProfile extends Record { /** the user's id */ sub: string; /** the user's username */ - preferred_username: string; + preferred_username?: string | undefined; /** the user's display name, will return the same value as the preferred_username if not set */ - nickname: string; + nickname?: string | undefined; /** the user's display name, again, will return the same value as the preferred_username if not set */ - name: string; + name?: string | undefined; /** the account creation date as a unix timestamp in seconds */ - created_at: number; + created_at?: number | undefined; /** the user's profile URL */ - profile: string; + profile?: string | undefined; /** the user's avatar URL */ - picture: string; + picture?: string | null | undefined; } export interface RobloxOptions extends ProviderOptions { @@ -101,7 +101,7 @@ export const roblox = (options: RobloxOptions) => { return { user: { name: profile.nickname || profile.preferred_username || "", - image: profile.picture, + image: profile.picture ?? undefined, email: createPlaceholderEmail({ identifier: profile.sub, namespace: "roblox",