Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/app/admin/__tests__/update-client-scopes-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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();
});

Expand Down
4 changes: 2 additions & 2 deletions src/app/admin/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/app/admin/clients/[clientId]/client-forms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -1110,7 +1110,7 @@ export function UpdateClientScopesForm({
<div>
<p className="text-sm font-semibold text-foreground">Allowed scopes</p>
<p className="text-xs text-muted-foreground">
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}$"}.
</p>
</div>
<div className="flex flex-wrap gap-2" data-testid="scope-chip-list">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<UpdateClientScopesForm clientId="client_1" initialScopes={["openid"]} canEdit />);

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" });
Expand Down
25 changes: 25 additions & 0 deletions src/server/oidc/__tests__/oidc-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
9 changes: 5 additions & 4 deletions src/server/oidc/__tests__/scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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);
});
});
4 changes: 2 additions & 2 deletions src/server/oidc/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
const normalized: string[] = [];
for (const scope of scopes) {
const value = scope.trim().toLowerCase();
const value = scope.trim();
if (!value || seen.has(value)) {
continue;
}
Expand Down
Loading