Skip to content

Commit 350e229

Browse files
[codex] Structure OAuth scope encoding failures (#3368)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 8112aff commit 350e229

2 files changed

Lines changed: 57 additions & 6 deletions

File tree

packages/shared/src/oauthScope.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { describe, expect, it } from "vite-plus/test";
2+
import * as Schema from "effect/Schema";
23

3-
import { encodeOAuthScope, parseAllowedOAuthScope, parseOAuthScope } from "./oauthScope.ts";
4+
import {
5+
encodeOAuthScope,
6+
OAuthScopeEncodingError,
7+
parseAllowedOAuthScope,
8+
parseOAuthScope,
9+
} from "./oauthScope.ts";
10+
11+
const isOAuthScopeEncodingError = Schema.is(OAuthScopeEncodingError);
412

513
describe("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

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
1+
import * as Schema from "effect/Schema";
2+
13
const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u;
24

5+
export 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

2035
export 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

2954
export function oauthScopeSetEquals(value: string, expectedScopes: ReadonlyArray<string>): boolean {

0 commit comments

Comments
 (0)