From 54ab459588393a58ea906977c1ffc8996d8d0700 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 24 May 2026 16:25:37 +0100 Subject: [PATCH] fix(oauth-provider): accept query-only granular scopes (#175) `parseScope` only looked for `:` to find the resource prefix, so valid canonical scopes using the `prefix?query` form (e.g. a multi-collection `repo?collection=a&collection=b` produced by expanding a permission set) were rejected as `Unknown scope resource`. Use the earlier of `:` and `?` as the delimiter, matching `@atproto/oauth-scopes` syntax. --- .changeset/oauth-scope-query-form.md | 5 +++++ packages/oauth-provider/src/scopes.ts | 5 ++++- packages/oauth-provider/test/scopes.test.ts | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .changeset/oauth-scope-query-form.md diff --git a/.changeset/oauth-scope-query-form.md b/.changeset/oauth-scope-query-form.md new file mode 100644 index 0000000..52998ba --- /dev/null +++ b/.changeset/oauth-scope-query-form.md @@ -0,0 +1,5 @@ +--- +"@getcirrus/oauth-provider": patch +--- + +Fix `parseScope` rejecting valid granular scopes that use the query-only form (e.g. `repo?collection=a&collection=b`) with `Unknown scope resource`. The parser previously only looked for `:` as the prefix delimiter, but per `@atproto/oauth-scopes` syntax a scope can use `prefix:positional`, `prefix?query`, or both. This affected permission sets whose `repo` permission listed multiple collections, since those expand to a single query-form token. diff --git a/packages/oauth-provider/src/scopes.ts b/packages/oauth-provider/src/scopes.ts index 2769f5c..72b4c2a 100644 --- a/packages/oauth-provider/src/scopes.ts +++ b/packages/oauth-provider/src/scopes.ts @@ -124,7 +124,10 @@ export function parseScope( } const colon = scope.indexOf(":"); - const resource = colon === -1 ? scope : scope.slice(0, colon); + const question = scope.indexOf("?"); + const end = + colon === -1 ? question : question === -1 ? colon : Math.min(colon, question); + const resource = end === -1 ? scope : scope.slice(0, end); const parser = STRUCTURAL_PARSERS[ resource as (typeof GRANULAR_RESOURCES)[number] diff --git a/packages/oauth-provider/test/scopes.test.ts b/packages/oauth-provider/test/scopes.test.ts index 2669015..b41529a 100644 --- a/packages/oauth-provider/test/scopes.test.ts +++ b/packages/oauth-provider/test/scopes.test.ts @@ -60,6 +60,21 @@ describe("parseScope", () => { expect(() => parseScope("atproto madeup:thing")).toThrow(ScopeParseError); }); + it("accepts repo scopes in query-only form (no positional)", () => { + const set = parseScope("atproto repo?collection=app.bsky.feed.post"); + expect(set.has("repo?collection=app.bsky.feed.post")).toBe(true); + }); + + it("accepts repo scopes with multiple collections", () => { + const scope = + "repo?collection=site.standard.document" + + "&collection=site.standard.graph.recommend" + + "&collection=site.standard.graph.subscription" + + "&collection=site.standard.publication"; + const set = parseScope(`atproto ${scope}`); + expect(set.has(scope)).toBe(true); + }); + it("rejects include: scopes by default (strict mode)", () => { expect(() => parseScope("atproto include:com.example.basic?aud=did:web:foo%23svc"), -- knotmirror 0.0.1