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
28 changes: 27 additions & 1 deletion packages/shared/src/oauthScope.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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.",
);
}
});
});
35 changes: 30 additions & 5 deletions packages/shared/src/oauthScope.ts
Original file line number Diff line number Diff line change
@@ -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>()(
"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.
Expand All @@ -18,12 +33,22 @@ export function parseOAuthScope(value: string): ReadonlyArray<string> | null {
}

export function encodeOAuthScope(scopes: ReadonlyArray<string>): 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<string>();
const duplicateScopes = new Set<string>();
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<string>): boolean {
Expand Down
Loading