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;
}