fix(roblox)!: model scoped userinfo claims

This commit is contained in:
Taesu
2026-10-16 09:59:10 +09:00
parent 2c7833fad6
commit a1e4647345
3 changed files with 87 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@better-auth/core": minor
---
Correct Roblox profile types for optional scoped claims and nullable pictures, and normalize missing local images.
@@ -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<string>();
expectTypeOf(rawProfile.preferred_username).toEqualTypeOf<
string | undefined
>();
expectTypeOf(rawProfile.nickname).toEqualTypeOf<string | undefined>();
expectTypeOf(rawProfile.name).toEqualTypeOf<string | undefined>();
expectTypeOf(rawProfile.created_at).toEqualTypeOf<number | undefined>();
expectTypeOf(rawProfile.profile).toEqualTypeOf<string | undefined>();
expectTypeOf(rawProfile.picture).toEqualTypeOf<
string | null | undefined
>();
expectTypeOf(rawProfile.custom_claim).toEqualTypeOf<unknown>();
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();
},
);
});
+8 -8
View File
@@ -7,21 +7,21 @@ import {
} from "../oauth2";
import { createPlaceholderEmail } from "../utils/email";
export interface RobloxProfile extends Record<string, any> {
export interface RobloxProfile extends Record<string, unknown> {
/** 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<RobloxProfile> {
@@ -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",