diff --git a/src/app/admin/__tests__/update-client-scopes-action.test.ts b/src/app/admin/__tests__/update-client-scopes-action.test.ts index 5879508..6104eb0 100644 --- a/src/app/admin/__tests__/update-client-scopes-action.test.ts +++ b/src/app/admin/__tests__/update-client-scopes-action.test.ts @@ -88,11 +88,14 @@ describe("updateClientScopesAction", () => { it("allows custom scopes that match the required pattern", async () => { const result = await updateClientScopesAction({ clientId: "client_internal", - scopes: ["openid", "profile", "tenant:write"], + scopes: ["openid", "profile", "r_organizationSocialAnalytics"], }); - expect(result).toEqual({ success: "Scopes updated", data: { allowedScopes: ["openid", "profile", "tenant:write"] } }); - expect(mockUpdateScopes).toHaveBeenCalledWith("client_internal", ["openid", "profile", "tenant:write"]); + expect(result).toEqual({ + success: "Scopes updated", + data: { allowedScopes: ["openid", "profile", "r_organizationSocialAnalytics"] }, + }); + expect(mockUpdateScopes).toHaveBeenCalledWith("client_internal", ["openid", "profile", "r_organizationSocialAnalytics"]); }); it("returns an error when openid is missing", async () => { @@ -105,7 +108,7 @@ describe("updateClientScopesAction", () => { it("returns an error for invalid scope formats", async () => { const result = await updateClientScopesAction({ clientId: "client_internal", scopes: ["openid", "invalid scope"] }); - expect(result).toEqual({ error: "Scopes must match ^[a-z0-9:_-]{1,64}$: invalid scope" }); + expect(result).toEqual({ error: "Scopes must match ^[A-Za-z0-9:_-]{1,64}$: invalid scope" }); expect(mockUpdateScopes).not.toHaveBeenCalled(); }); diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts index 06b6f1a..b49c26b 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -504,7 +504,7 @@ export const createClientAction = async ( } const invalid = normalizedScopes.filter((scope) => !isValidScopeValue(scope)); if (invalid.length > 0) { - return { error: `Scopes must match ^[a-z0-9:_-]{1,64}$: ${invalid.join(", ")}` }; + return { error: `Scopes must match ^[A-Za-z0-9:_-]{1,64}$: ${invalid.join(", ")}` }; } const canonicalScopes = ["openid", ...normalizedScopes.filter((scope) => scope !== "openid")]; const proxyConfigResult = parsed.mode === "proxy" @@ -1328,7 +1328,7 @@ export const updateClientScopesAction = async ( } const invalid = normalized.filter((scope) => !isValidScopeValue(scope)); if (invalid.length > 0) { - return { error: `Scopes must match ^[a-z0-9:_-]{1,64}$: ${invalid.join(", ")}` }; + return { error: `Scopes must match ^[A-Za-z0-9:_-]{1,64}$: ${invalid.join(", ")}` }; } const canonical = ["openid", ...normalized.filter((scope) => scope !== "openid")]; const updated = await updateClientAllowedScopes(client.id, canonical); diff --git a/src/app/admin/clients/[clientId]/client-forms.tsx b/src/app/admin/clients/[clientId]/client-forms.tsx index 71709a2..1be9f65 100644 --- a/src/app/admin/clients/[clientId]/client-forms.tsx +++ b/src/app/admin/clients/[clientId]/client-forms.tsx @@ -1057,12 +1057,12 @@ export function UpdateClientScopesForm({ if (!canEdit || pending) { return; } - const candidate = raw.trim().toLowerCase(); + const candidate = raw.trim(); if (!candidate) { return; } if (!isValidScopeValue(candidate)) { - dispatch({ type: "setError", value: "Scopes must match ^[a-z0-9:_-]{1,64}$" }); + dispatch({ type: "setError", value: "Scopes must match ^[A-Za-z0-9:_-]{1,64}$" }); return; } dispatch({ type: "add", scope: candidate }); @@ -1110,7 +1110,7 @@ export function UpdateClientScopesForm({

Allowed scopes

- openid is required. Additional scopes must match the pattern {"^[a-z0-9:_-]{1,64}$"}. + openid is required. Additional scopes must match the pattern {"^[A-Za-z0-9:_-]{1,64}$"}.

diff --git a/src/app/admin/clients/__tests__/update-client-scopes-form.test.tsx b/src/app/admin/clients/__tests__/update-client-scopes-form.test.tsx index 50d7370..7ad567d 100644 --- a/src/app/admin/clients/__tests__/update-client-scopes-form.test.tsx +++ b/src/app/admin/clients/__tests__/update-client-scopes-form.test.tsx @@ -53,6 +53,22 @@ describe("UpdateClientScopesForm", () => { expect(mockToast).toHaveBeenCalledWith({ title: "Scopes updated", description: "Scopes saved" }); }); + it("preserves mixed-case scope input", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByTestId("scope-input"), "r_organizationSocialAnalytics"); + await user.keyboard("{Enter}"); + await user.click(screen.getByTestId("scope-save-button")); + + await waitFor(() => { + expect(mockUpdateClientScopesAction).toHaveBeenCalledWith({ + clientId: "client_1", + scopes: ["openid", "r_organizationSocialAnalytics"], + }); + }); + }); + it("shows an error when the action fails", async () => { const user = userEvent.setup(); mockUpdateClientScopesAction.mockResolvedValueOnce({ error: "Unsupported scope" }); diff --git a/src/server/oidc/__tests__/oidc-flow.test.ts b/src/server/oidc/__tests__/oidc-flow.test.ts index f047c35..27be6ca 100644 --- a/src/server/oidc/__tests__/oidc-flow.test.ts +++ b/src/server/oidc/__tests__/oidc-flow.test.ts @@ -359,6 +359,31 @@ describe("OIDC flow", () => { ).rejects.toThrowError("Client does not allow scopes: offline_access"); }); + it("allows mixed-case scopes in the client allowlist", async () => { + await prisma.client.update({ + where: { id: clientInternalId }, + data: { allowedScopes: ["openid", "r_organizationSocialAnalytics"] }, + }); + const challenge = computeS256Challenge(codeVerifier); + const authorize = await handleAuthorize( + { + apiResourceId, + clientId: CLIENT_ID, + redirectUri: "https://client.example.test/callback", + responseType: "code", + scope: "openid r_organizationSocialAnalytics", + codeChallenge: challenge, + codeChallengeMethod: "S256", + sessionToken, + reauthCookie: buildReauthCookie(sessionToken), + }, + "https://mockauth.test", + `https://mockauth.test/r/${apiResourceId}/oidc/authorize?client_id=${CLIENT_ID}`, + ); + + expect(authorize.type).toBe("redirect"); + }); + it("rejects scopes disabled for the client", async () => { await prisma.client.update({ where: { id: clientInternalId }, diff --git a/src/server/oidc/__tests__/scopes.test.ts b/src/server/oidc/__tests__/scopes.test.ts index 0e213a5..faa9b97 100644 --- a/src/server/oidc/__tests__/scopes.test.ts +++ b/src/server/oidc/__tests__/scopes.test.ts @@ -5,12 +5,12 @@ import { isSupportedScope, isValidScopeValue, normalizeScopes, SUPPORTED_SCOPES describe("OIDC scopes", () => { it("normalizes scope values", () => { const result = normalizeScopes([" OpenID ", "profile", "EMAIL", "profile", ""]); - expect(result).toEqual(["openid", "profile", "email"]); + expect(result).toEqual(["OpenID", "profile", "EMAIL"]); }); it("preserves insertion order for unique scopes", () => { - const result = normalizeScopes(["email", "profile", "email", "openid"]); - expect(result).toEqual(["email", "profile", "openid"]); + const result = normalizeScopes(["email", "Profile", "email", "profile", "openid"]); + expect(result).toEqual(["email", "Profile", "profile", "openid"]); }); it("detects supported scopes", () => { @@ -24,7 +24,8 @@ describe("OIDC scopes", () => { expect(isValidScopeValue("openid")).toBe(true); expect(isValidScopeValue("tenant:admin")).toBe(true); expect(isValidScopeValue("invalid scope")).toBe(false); - expect(isValidScopeValue("UPPERCASE")).toBe(false); + expect(isValidScopeValue("UPPERCASE")).toBe(true); + expect(isValidScopeValue("r_organizationSocialAnalytics")).toBe(true); expect(isValidScopeValue("a".repeat(65))).toBe(false); }); }); diff --git a/src/server/oidc/scopes.ts b/src/server/oidc/scopes.ts index 4a380f5..63ae8eb 100644 --- a/src/server/oidc/scopes.ts +++ b/src/server/oidc/scopes.ts @@ -4,13 +4,13 @@ export const DEFAULT_ALLOWED_SCOPES = ["openid", "profile", "email"] as const; export type SupportedScope = (typeof SUPPORTED_SCOPES)[number]; -export const SCOPE_VALUE_PATTERN = /^[a-z0-9:_-]{1,64}$/; +export const SCOPE_VALUE_PATTERN = /^[A-Za-z0-9:_-]{1,64}$/; export const normalizeScopes = (scopes: string[]): string[] => { const seen = new Set(); const normalized: string[] = []; for (const scope of scopes) { - const value = scope.trim().toLowerCase(); + const value = scope.trim(); if (!value || seen.has(value)) { continue; }