diff --git a/packages/shared/src/oauthScope.test.ts b/packages/shared/src/oauthScope.test.ts index 0aa4ef595a8..f5cc247a24a 100644 --- a/packages/shared/src/oauthScope.test.ts +++ b/packages/shared/src/oauthScope.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; -import { encodeOAuthScope, parseAllowedOAuthScope, parseOAuthScope } from "./oauthScope.ts"; +import { + encodeOAuthScope, + OAuthScopeEncodingError, + parseAllowedOAuthScope, + parseOAuthScope, +} from "./oauthScope.ts"; + +const isOAuthScopeEncodingError = Schema.is(OAuthScopeEncodingError); describe("OAuth scopes", () => { it("parses an RFC 6749 space-delimited scope set without duplicating permissions", () => { @@ -32,4 +40,22 @@ describe("OAuth scopes", () => { }), ).toBeNull(); }); + + it("reports invalid encoding input structurally", () => { + expect.assertions(5); + + try { + encodeOAuthScope(["access:read", "invalid scope", "access:read"]); + } catch (error) { + expect(error).toBeInstanceOf(OAuthScopeEncodingError); + if (!isOAuthScopeEncodingError(error)) return; + + expect(error.scopes).toEqual(["access:read", "invalid scope", "access:read"]); + expect(error.invalidScopes).toEqual(["invalid scope"]); + expect(error.duplicateScopes).toEqual(["access:read"]); + expect(error.message).toBe( + "OAuth scopes must be non-empty, syntactically valid, and unique.", + ); + } + }); }); diff --git a/packages/shared/src/oauthScope.ts b/packages/shared/src/oauthScope.ts index 47c6dd7051b..4f427440660 100644 --- a/packages/shared/src/oauthScope.ts +++ b/packages/shared/src/oauthScope.ts @@ -1,5 +1,20 @@ +import * as Schema from "effect/Schema"; + const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u; +export class OAuthScopeEncodingError extends Schema.TaggedErrorClass()( + "OAuthScopeEncodingError", + { + scopes: Schema.Array(Schema.String), + invalidScopes: Schema.Array(Schema.String), + duplicateScopes: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return "OAuth scopes must be non-empty, syntactically valid, and unique."; + } +} + /** * Decodes an RFC 6749 `scope` value as a set while preserving its first-seen * order for canonical responses and logs. @@ -18,12 +33,22 @@ export function parseOAuthScope(value: string): ReadonlyArray | null { } export function encodeOAuthScope(scopes: ReadonlyArray): string { - const encoded = scopes.join(" "); - const parsed = parseOAuthScope(encoded); - if (parsed === null || parsed.length !== scopes.length) { - throw new Error("OAuth scopes must be non-empty, valid, and unique."); + const invalidScopes = scopes.filter((scope) => !OAUTH_SCOPE_TOKEN.test(scope)); + const seen = new Set(); + const duplicateScopes = new Set(); + for (const scope of scopes) { + if (seen.has(scope)) duplicateScopes.add(scope); + seen.add(scope); + } + + if (scopes.length === 0 || invalidScopes.length > 0 || duplicateScopes.size > 0) { + throw new OAuthScopeEncodingError({ + scopes, + invalidScopes, + duplicateScopes: [...duplicateScopes], + }); } - return encoded; + return scopes.join(" "); } export function oauthScopeSetEquals(value: string, expectedScopes: ReadonlyArray): boolean {