| author | |
| committer | |
| log | 350e229f7a5d5c4cdc9a29cf2d1f911ef27a9a48 |
| tree | badb1190842b03ed28093cb2389e1695f61091ed |
| parent | 8112aff7c2dc8a82447920e6fde232565149d32c |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import * as Schema from "effect/Schema"; | |
| 2 | 3 | |
| 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); | |
| 4 | 12 | |
| 5 | 13 | describe("OAuth scopes", () => { |
| 6 | 14 | it("parses an RFC 6749 space-delimited scope set without duplicating permissions", () => { |
| ... | ... | @@ -32,4 +40,22 @@ describe("OAuth scopes", () => { |
| 32 | 40 | }), |
| 33 | 41 | ).toBeNull(); |
| 34 | 42 | }); |
| 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 | }); | |
| 35 | 61 | }); |
packages/shared/src/oauthScope.ts+30-5| ... | ... | @@ -1,5 +1,20 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 1 | 3 | const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u; |
| 2 | 4 | |
| 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 | ||
| 3 | 18 | /** |
| 4 | 19 | * Decodes an RFC 6749 `scope` value as a set while preserving its first-seen |
| 5 | 20 | * order for canonical responses and logs. |
| ... | ... | @@ -18,12 +33,22 @@ export function parseOAuthScope(value: string): ReadonlyArray<string> | null { |
| 18 | 33 | } |
| 19 | 34 | |
| 20 | 35 | 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 | }); | |
| 25 | 50 | } |
| 26 | return encoded; | |
| 51 | return scopes.join(" "); | |
| 27 | 52 | } |
| 28 | 53 | |
| 29 | 54 | export function oauthScopeSetEquals(value: string, expectedScopes: ReadonlyArray<string>): boolean { |