import * as Schema from "effect/Schema"; const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u; export class OAuthScopeEncodingError extends Schema.TaggedErrorClass()( "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. */ export function parseOAuthScope(value: string): ReadonlyArray | null { if (value.length === 0) { return null; } const scopes = value.split(" "); if (scopes.some((scope) => !OAUTH_SCOPE_TOKEN.test(scope))) { return null; } return [...new Set(scopes)]; } export function encodeOAuthScope(scopes: ReadonlyArray): string { const invalidScopes = scopes.filter((scope) => !OAUTH_SCOPE_TOKEN.test(scope)); const seen = new Set(); const duplicateScopes = new Set(); 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 scopes.join(" "); } export function oauthScopeSetEquals(value: string, expectedScopes: ReadonlyArray): boolean { const scopes = parseOAuthScope(value); return ( scopes !== null && scopes.length === new Set(expectedScopes).size && scopes.every((scope) => expectedScopes.includes(scope)) ); } export function parseAllowedOAuthScope(input: { readonly value: string; readonly allowedScopes: ReadonlySet; }): ReadonlyArray | null { const scopes = parseOAuthScope(input.value); if ( scopes === null || !scopes.every((scope): scope is Scope => input.allowedScopes.has(scope as Scope)) ) { return null; } return scopes; }