1import * as Schema from "effect/Schema";
2
3const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u;
4
5export class OAuthScopeEncodingError extends Schema.TaggedError<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
18/**
19 * Decodes an RFC 6749 `scope` value as a set while preserving its first-seen
20 * order for canonical responses and logs.
21 */
22export function parseOAuthScope(value: string): ReadonlyArray<string> | null {
23 if (value.length === 0) {
24 return null;
25 }
26
27 const scopes = value.split(" ");
28 if (scopes.some((scope) => !OAUTH_SCOPE_TOKEN.test(scope))) {
29 return null;
30 }
31
32 return [...new Set(scopes)];
33}
34
35export function encodeOAuthScope(scopes: ReadonlyArray<string>): string {
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 });
50 }
51 return scopes.join(" ");
52}
53
54export function oauthScopeSetEquals(value: string, expectedScopes: ReadonlyArray<string>): boolean {
55 const scopes = parseOAuthScope(value);
56 return (
57 scopes !== null &&
58 scopes.length === new Set(expectedScopes).size &&
59 scopes.every((scope) => expectedScopes.includes(scope))
60 );
61}
62
63export function parseAllowedOAuthScope<Scope extends string>(input: {
64 readonly value: string;
65 readonly allowedScopes: ReadonlySet<Scope>;
66}): ReadonlyArray<Scope> | null {
67 const scopes = parseOAuthScope(input.value);
68 if (
69 scopes === null ||
70 !scopes.every((scope): scope is Scope => input.allowedScopes.has(scope as Scope))
71 ) {
72 return null;
73 }
74 return scopes;
75}