authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:05:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:05:40-07:00
log350e229f7a5d5c4cdc9a29cf2d1f911ef27a9a48
treebadb1190842b03ed28093cb2389e1695f61091ed
parent8112aff7c2dc8a82447920e6fde232565149d32c
signature Signed by PGP key B5690EEEBB952194

[codex] Structure OAuth scope encoding failures (#3368)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 57 insertions(+), 6 deletions(-)

packages/shared/src/oauthScope.test.ts+27-1
......@@ -1,6 +1,14 @@
11import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";
23
3import { encodeOAuthScope, parseAllowedOAuthScope, parseOAuthScope } from "./oauthScope.ts";
4import {
5 encodeOAuthScope,
6 OAuthScopeEncodingError,
7 parseAllowedOAuthScope,
8 parseOAuthScope,
9} from "./oauthScope.ts";
10
11const isOAuthScopeEncodingError = Schema.is(OAuthScopeEncodingError);
412
513describe("OAuth scopes", () => {
614 it("parses an RFC 6749 space-delimited scope set without duplicating permissions", () => {
......@@ -32,4 +40,22 @@ describe("OAuth scopes", () => {
3240 }),
3341 ).toBeNull();
3442 });
43
44 it("reports invalid encoding input structurally", () => {
45 expect.assertions(5);
46
47 try {
48 encodeOAuthScope(["access:read", "invalid scope", "access:read"]);
49 } catch (error) {
50 expect(error).toBeInstanceOf(OAuthScopeEncodingError);
51 if (!isOAuthScopeEncodingError(error)) return;
52
53 expect(error.scopes).toEqual(["access:read", "invalid scope", "access:read"]);
54 expect(error.invalidScopes).toEqual(["invalid scope"]);
55 expect(error.duplicateScopes).toEqual(["access:read"]);
56 expect(error.message).toBe(
57 "OAuth scopes must be non-empty, syntactically valid, and unique.",
58 );
59 }
60 });
3561});
packages/shared/src/oauthScope.ts+30-5
......@@ -1,5 +1,20 @@
1import * as Schema from "effect/Schema";
2
13const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u;
24
5export class OAuthScopeEncodingError extends Schema.TaggedErrorClass<OAuthScopeEncodingError>()(
6 "OAuthScopeEncodingError",
7 {
8 scopes: Schema.Array(Schema.String),
9 invalidScopes: Schema.Array(Schema.String),
10 duplicateScopes: Schema.Array(Schema.String),
11 },
12) {
13 override get message(): string {
14 return "OAuth scopes must be non-empty, syntactically valid, and unique.";
15 }
16}
17
318/**
419 * Decodes an RFC 6749 `scope` value as a set while preserving its first-seen
520 * order for canonical responses and logs.
......@@ -18,12 +33,22 @@ export function parseOAuthScope(value: string): ReadonlyArray<string> | null {
1833}
1934
2035export function encodeOAuthScope(scopes: ReadonlyArray<string>): string {
21 const encoded = scopes.join(" ");
22 const parsed = parseOAuthScope(encoded);
23 if (parsed === null || parsed.length !== scopes.length) {
24 throw new Error("OAuth scopes must be non-empty, valid, and unique.");
36 const invalidScopes = scopes.filter((scope) => !OAUTH_SCOPE_TOKEN.test(scope));
37 const seen = new Set<string>();
38 const duplicateScopes = new Set<string>();
39 for (const scope of scopes) {
40 if (seen.has(scope)) duplicateScopes.add(scope);
41 seen.add(scope);
42 }
43
44 if (scopes.length === 0 || invalidScopes.length > 0 || duplicateScopes.size > 0) {
45 throw new OAuthScopeEncodingError({
46 scopes,
47 invalidScopes,
48 duplicateScopes: [...duplicateScopes],
49 });
2550 }
26 return encoded;
51 return scopes.join(" ");
2752}
2853
2954export function oauthScopeSetEquals(value: string, expectedScopes: ReadonlyArray<string>): boolean {