Skip to content

fix(oauth): filter unsupported scopes before parsing - #203

Open
andrewsouthard wants to merge 2 commits into
ascorbic:mainfrom
andrewsouthard:fix/scope-parse-filter-unrecognized
Open

fix(oauth): filter unsupported scopes before parsing#203
andrewsouthard wants to merge 2 commits into
ascorbic:mainfrom
andrewsouthard:fix/scope-parse-filter-unrecognized

Conversation

@andrewsouthard

@andrewsouthard andrewsouthard commented Jul 12, 2026

Copy link
Copy Markdown

Problem
Unsupported OAuth scopes throw instead of filtering them out.

Fix
Use isAtprotoOauthScope to filter scopes before parsing. This allows us to remove some parsing code added to handle invalid and malformed scopes.

Testing

  • Updated unit tests
  • Deploy changes to my PDS, login to postgame. This failed on v0.18, it works with the patch applied.

Fixes issue #198

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — dropping unsupported scopes rather than failing the request is the right call for interop (it's what the reference provider does), so I'm keen to get this in. As it stands it's only half the change though: parseScope returns the filtered set but none of the callers use the return value, so the raw string is still stored and issued. I reproduced that end-to-end on this branch — details inline.

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline notes for the review above.

{ allowIncludes = false }: ParseScopeOptions = {},
): ScopesSet {
const set = ScopesSet.fromString(input ?? "");
const filtered =

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filtering happens here, but nothing consumes it. Every caller ignores the return value and keeps the raw string:

  • par.ts ~L186: params.scope = scope (raw), then parseScope(scope) result dropped
  • provider.ts ~L382 (authorize GET): same
  • provider.ts ~L524: scope = requestedScope goes into authCodeData.scope
  • provider.ts ~L1122 (authorize POST): same
  • then generateTokens({ scope: codeData.scope }) and it rides through refresh

Reproduced on this branch with a full PAR → consent → token flow using atproto repo:com.example.thing?action=read madeup:thing include:bad: PAR 201, consent 200, and the token response is "scope": "atproto repo:com.example.thing?action=read madeup:thing include:bad" — junk and all.

That matters for three reasons: the token response's scope doesn't reflect what was granted (RFC 6749 §5.1); include:bad sneaks past the allowIncludes check below because it's filtered out before that loop runs; and — the one I actually care about — a scope we can't parse today gets stored verbatim and becomes a live permission the day @atproto/oauth-scopes is bumped to a version that understands it, without ever appearing on the consent screen. The reference implementation does parameters = { ...parameters, scope } to replace the stored value with the filtered one for exactly this reason.

Suggest: return (or expose) the filtered scope string and assign it back to params.scope / scope at those four sites before anything is stored.

expect(response.status).toBe(400);
const json = (await response.json()) as { error: string };
expect(json.error).toBe("invalid_scope");
expect(response.status).toBe(201);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would pass even if nothing was filtered. Once the callers are wired up, please carry this through to the token step and assert the response scope omits the dropped token — that's the test that pins the behaviour.

it("rejects malformed granular scopes", () => {
expect(() => parseScope("atproto repo:not a real nsid")).toThrow(
ScopeParseError,
it("silently drops malformed scope tokens", () => {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assert on a return value nothing in production consumes yet, so they don't really replace the deleted "rejects malformed / unknown" tests. Fine to keep as unit coverage of parseScope itself once the flow test above exists.

Comment thread packages/oauth-provider/src/scopes.ts Outdated

export interface ParseScopeOptions {
/**
* When true, `include:` scopes are accepted (and structurally validated)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale now — this and the file header both still describe structural validation, which no longer happens here.

}
if (!parser(scope)) {
throw new ScopeParseError(`Malformed scope: ${scope}`, scope);
if (scope.startsWith("include:") && !allowIncludes) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note include:<garbage> never reaches this check any more — isAtprotoOauthScope drops it before the loop. That's fine if the filtered string is what gets stored; with the current callers it's stored anyway.

@JNaftali

Copy link
Copy Markdown

I ran into this with my personal pds and had Claude whip up a fix. I do not vouch for that output except that I deployed it recklessly and my personal PDS has not exploded yet, haha. Feel free to scavenge if useful andrewsouthard#1

@andrewsouthard

Copy link
Copy Markdown
Author

Thanks for the review and helpful pointers @ascorbic ! I've reworked the implementation based on your feedback. Happy to tweak it further if you see anything else I missed. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants