Skip to content

Honor the offline_access scope opt-in for refresh tokens (SEP-2207)#192

Merged
heskew merged 3 commits into
mainfrom
offline-access-scope
Jul 23, 2026
Merged

Honor the offline_access scope opt-in for refresh tokens (SEP-2207)#192
heskew merged 3 commits into
mainfrom
offline-access-scope

Conversation

@heskew

@heskew heskew commented Jul 21, 2026

Copy link
Copy Markdown
Member

Part of #156 (item 2 — the SEP-2207 offline_access MAY; SEP-2350 remains blocked on per-tool scopes and the July 28 final-spec re-verify stays tracked there).

Summary

  • AS metadata now advertises scopes_supported: ["offline_access"] — per the MCP spec, clients MAY add offline_access to their scope when AS metadata lists it. RFC 8414 permits a partial list; upstream-provider scopes stay opaque pass-through.
  • PRM deliberately continues to omit scopes_supported (refresh tokens are not a resource requirement) — now documented in-code and pinned by a test.
  • New opt-in config mcp.refreshTokenRequiresOfflineAccess (default false): when set, the authorization_code exchange withholds refresh tokens unless the granted scope carries offline_access. Normalization of the flag is total (normalizeMcpSecurityConfig): coercible strings coerce, and any other present value — including an unresolved ${FLAG} placeholder, which review caught surviving as a truthy string — is dropped with a startup warning so the documented default applies. The same total normalization now covers every documented boolean in the mcp block.

Design decision to weigh

Default behavior is unchanged: any client whose registered grant_types include refresh_token still receives one without requesting offline_access. Making the scope mandatory by default would force hourly re-auth for every MCP client that never sends it (most of them), so strictness is opt-in. If you'd rather flip the default, it's a one-line config-default change.

Review notes

Cross-model review ran (Codex + Claude domain pass; Gemini leg unavailable). No blockers; the config-coercion finding was addressed as above. Existing refresh-token families are unaffected when the flag is flipped on (deliberate — no retro-revocation).

1084 unit tests pass (10 new: gate on/off/grant_types interplay, metadata advertisement, PRM omission, config coercion).

Generated by an LLM (Claude Fable 5) pairing with @heskew.

🤖 Generated with Claude Code

https://claude.ai/code/session_0185AoLyjuZUuKAwg9NxUDiq

)

AS metadata now advertises scopes_supported: [offline_access], which per
the MCP spec invites clients that want refresh tokens to request it
explicitly. PRM deliberately continues to omit scopes_supported (refresh
tokens are not a resource requirement). A new opt-in config,
mcp.refreshTokenRequiresOfflineAccess (default false), makes the
authorization_code exchange withhold refresh tokens unless the granted
scope carries offline_access; the default policy is unchanged so
existing MCP clients that never send offline_access keep receiving
refresh tokens. Existing refresh-token families are unaffected either
way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185AoLyjuZUuKAwg9NxUDiq
@heskew
heskew requested a review from kriszyp July 21, 2026 22:44

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the mcp.refreshTokenRequiresOfflineAccess configuration option to support the SEP-2207 specification, allowing operators to require the offline_access scope before issuing refresh tokens. The changes include configuration normalization, token issuance logic updates, and metadata advertising, accompanied by corresponding documentation and unit tests. There are no review comments, and I have no feedback to provide.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@github-actions

This comment has been minimized.

@heskew

heskew commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

Re the gemini-review "blocker" (rotation bypasses the opt-in): adjudicated as not a blocker — the premise is incorrect. Rotation keeps the family's original expires_at (token.ts — "Rotate only once the new access token is in hand (keep the original expiry)"; only current_token_hash is rewritten), so a pre-flag family cannot renew indefinitely: it hard-stops at its original expiry (≤ refreshTokenTtl, default 30d). Within that bounded window, honoring already-issued grants is the documented, deliberate no-retro-revocation behavior (types.ts, docs, and the PR description's "Design decision to weigh"). The suggested fix would also corrupt the family hash (current_token_hash = 'drained') and strand in-flight sessions — exactly the retro-revocation this PR intentionally avoids. If we ever want strict draining, it should be a separate opt-in with real family-termination semantics. Resolving.


🤖 Posted by Claude on Nathan's behalf

Comment thread src/lib/mcp/token.ts
…ing)

An unresolved ${FLAG} placeholder (or any other non-coercible value) on a
documented boolean survived normalization as a truthy string and flipped
whichever direction the consuming gate tested — e.g. activating the
default-off refreshTokenRequiresOfflineAccess gate (caught in review).
normalizeMcpSecurityConfig now guarantees every documented boolean
(enabled, refreshTokenRequiresOfflineAccess, clientCredentials.enabled,
dynamicClientRegistration.enabled, clientIdMetadataDocuments.enabled) is
a real boolean or absent: coercible values coerce, anything else is
dropped with a startup warning so the documented default applies.
Consumers keep gating on plain truthiness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185AoLyjuZUuKAwg9NxUDiq
Comment thread test/lib/mcp/token.test.js
@heskew

heskew commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Re the updated gemini-review comment (drain on rotation, now non-blocking): the adjudication above stands — rotation keeps the family's original expires_at, so the window is bounded by refreshTokenTtl, and honoring already-issued grants without retro-revocation is the documented design. A strict drain mode would be a separate opt-in with real family-termination semantics (not a sentinel current_token_hash), if ever needed.


🤖 Posted by Claude on Nathan's behalf

@heskew heskew left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed the current head (160992d). No blockers found.

The OAuth/MCP-specific behavior looks sound: offline_access is advertised by the authorization server but omitted from protected-resource metadata, invalid boolean configuration is normalized at the boundary, and the opt-in gate applies to authorization-code issuance without retroactively revoking existing refresh families. Those families retain their original bounded expires_at, so refresh rotation does not create an indefinite bypass.

The branch passes the unit suite, lint, unsuppressed TypeScript checking, and git diff --check.


🤖 Posted by Codex on Nathan's behalf.

# Conflicts:
#	docs/configuration.md
#	test/lib/mcp/wellKnown.test.js
Comment thread test/lib/mcp/token.test.js
@heskew
heskew merged commit 7b317e7 into main Jul 23, 2026
12 checks passed
@heskew
heskew deleted the offline-access-scope branch July 23, 2026 00:46
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.

2 participants