From ef47baf0a14379e37afe1b96fa2f6cf604e3337f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 6 Nov 2026 14:35:14 +0100 Subject: [PATCH] docs(quick-261106-jhk): MCP authorize drops OIDC identity scopes for the ChatGPT connector --- .planning/STATE.md | 1 + .../261106-jhk-PLAN.md | 180 ++++++++++++++++++ .../261106-jhk-SUMMARY.md | 94 +++++++++ 3 files changed, 275 insertions(+) create mode 100644 .planning/quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/261106-jhk-PLAN.md create mode 100644 .planning/quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/261106-jhk-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 66bff4f5..a4698e56 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1070,6 +1070,7 @@ Recent decisions affecting current work: | fast | ensureToolchain keeps an existing toolchain line instead of adding a second one | 2026-10-28 | 6ea2cb7 | — | | 261106-dv0 | shared plugins log boot details at Debug: paymentgateway operators registered, paymentgatewaysubscriptions registries collected through diary.For (todo 2026-10-28-plugins-log-boot-details-at-debug.md) | 2026-11-06 | 8a88b86, 1df6a26 | [261106-dv0-plugin-boot-logs-at-debug](./quick/261106-dv0-plugin-boot-logs-at-debug/) | | 261106-e5v | purge token rename leftovers: grzybyfunkcjonalne check-deploy refuses NUXT_SITE_* keys, journal/menu/faq READMEs name NUXT_CACHE_PURGE_TOKEN (todo 2026-10-20-rename-site-cache-purge-env-in-consumers.md) | 2026-11-06 | 4e2cf0c, f61c90a, 128a33d, a53511b | [261106-e5v-purge-token-rename-leftovers](./quick/261106-e5v-purge-token-rename-leftovers/) | +| 261106-jhk | wristband MCP authorize drops OIDC identity scopes (openid, profile, email) so the ChatGPT connector can connect | 2026-11-06 | aba678b | [261106-jhk-wristband-mcp-authorize-ignores-oidc-ide](./quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/) | ## Deferred Items diff --git a/.planning/quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/261106-jhk-PLAN.md b/.planning/quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/261106-jhk-PLAN.md new file mode 100644 index 00000000..aa404585 --- /dev/null +++ b/.planning/quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/261106-jhk-PLAN.md @@ -0,0 +1,180 @@ +--- +phase: quick-261106-jhk +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - modules/wristband/authorize.go + - modules/wristband/authorize_test.go + - modules/wristband/mcp_scopes_test.go + - modules/wristband/oidc_flavour_test.go + - modules/wristband/README.md + - docs/services/oauth-server.md +autonomous: true +requirements: + - quick:261106-jhk-mcp-authorize-drops-oidc-identity-scopes + +estimate: + tokens: 35000 + raw_tokens: 35000 + tasks: 2 + confidence: low + +must_haves: + truths: + - "On the MCP flavour, GET /oauth/mcp/authorize drops the exact, case-sensitive tokens openid, profile and email from the scope query value before the allow-list check (wristband.Server.Authorize -> parseAuthorizeScopes). The drop is unconditional: it happens even if a host lists one of the three in Options.ScopesSupported, so the MCP flavour never persists an identity scope on a pending row" + - "The request a hosted connector sends after reading the issuer's OIDC discovery document (response_type=code, scope=openid email read, S256 challenge, resource equal to Options.Resource, state, ui_locales=en-US) gets a 302 to /connect?request= with no error parameter, and the stored pending row has Scopes exactly [read], OfflineAccess false, the request's resource and state" + - "When only identity scopes are requested (openid profile, or openid alone) the existing empty-scope default applies and the pending row stores [read]. Only an empty list after dropping triggers the default; offline_access alone keeps its current behaviour" + - "Every other token outside the resolved allow-list is still refused with error=invalid_scope and error_description=scope contains an unsupported value, and no pending row is written: read bogus, openid bogus, read admin, OPENID read (case differs), and backend on DefaultOptions all stay refused. The client scope ceiling still runs after the drop" + - "Metadata scopes_supported is unchanged: identity scopes are not advertised (TestMCPScopesSmoke exact-slice assertions pass without edits). The OIDC flavour (oidc_authorize.go) is untouched. The MCP token endpoint still never emits an id_token (TestMCPRefusesOpenID token half unchanged), and its scope field already reports the narrowed grant" + - "TestAuthorizeScopeInvalidValueRedirectsInvalidScope (read bogus) and TestMCPScopesSmoke (read backend) keep their rejection assertions unedited; TestMCPScopes and TestMCPRefusesOpenID keep their names (scripts/check-phase19.4.sh and scripts/check-phase19.7.sh list them) but their openid cases now assert success with no identity scope persisted" + - "modules/wristband/README.md and docs/services/oauth-server.md describe the drop in the same commit as the code. One commit in summercms.go, files staged by explicit path, no co-author tag, nothing pushed or tagged. go vet ./..., go test -short ./..., TestDocsTree and summer docs:build --check are green at that commit" + artifacts: + - path: modules/wristband/authorize.go + provides: "parseAuthorizeScopes drops openid/profile/email before validating; doc comment states RFC 6749 section 3.3 and the PHP divergence" + contains: "mcpIgnoredIdentityScopes" + - path: modules/wristband/authorize_test.go + provides: "TestAuthorizeDropsOIDCIdentityScopes: the discovery-reading client's exact request, identity-only requests, ceiling interplay and the still-refused cases" + contains: "func TestAuthorizeDropsOIDCIdentityScopes" + - path: modules/wristband/mcp_scopes_test.go + provides: "TestMCPScopes subtest asserting identity scopes are dropped and unlisted scopes stay refused" + - path: modules/wristband/oidc_flavour_test.go + provides: "TestMCPRefusesOpenID: authorize cases succeed without an identity scope persisted; id_token half unchanged" + - path: modules/wristband/README.md + provides: "Features bullets (authorize endpoint, client kinds) describe the drop" + contains: "are dropped from the request" + - path: docs/services/oauth-server.md + provides: "Scope paragraph and client-kinds paragraph describe the drop" + contains: "are dropped from the request" + key_links: + - from: "wristband.Server.Authorize (authorize.go:109)" + to: "parseAuthorizeScopes" + via: "q.Get(\"scope\") and s.allowedAuthorizeScopes(); the drop lives inside parseAuthorizeScopes so the call site and the ceiling block after it are unchanged" + - from: "pending AuthCodeRecord.Scopes" + to: "consent view and token response" + via: "PendingRequest shows pending.Scopes and the token answer's scope field joins issued scopes, so both already reflect the narrowed grant with no further code change" +--- + + +Make the MCP authorization endpoint accept requests that carry the OpenID Connect identity scopes `openid`, `profile` and `email` by dropping them before validation, instead of redirecting with `invalid_scope`. + +Purpose: a client that reads both `/.well-known/oauth-authorization-server` and the same issuer's `/.well-known/openid-configuration` (published by wristband's OIDC flavour) adds `openid email` to its MCP authorize request and is currently refused with "scope contains an unsupported value", so it cannot connect. RFC 6749 section 3.3 lets the authorization server grant fewer scopes than requested, and the MCP flavour issues no ID tokens, so dropping these three is safe. The root cause is verified (production nginx log, 2026-11-06 12:58); do not re-investigate the live server. + +Investigation already done during planning (the executor does not repeat it): +- The token endpoint never reads a request `scope` parameter: the refresh grant reissues `issued.Scopes` (token.go:140). No change. +- The consent view (consent.go `PendingRequest`) shows the pending row's own scopes, which are filtered at authorize. No change. +- `TestAuthorizeScopeInvalidValueRedirectsInvalidScope` (authorize_test.go:465) requests `read bogus` and `TestMCPScopesSmoke` (mcp_scopes_smoke_test.go:69) requests `read backend` on DefaultOptions. Neither uses an identity scope, so both keep their rejection coverage unedited. +- Two tests DO break and must be rewritten: `TestMCPScopes` subtest "openid and unlisted scopes stay refused" (mcp_scopes_test.go:71-79) and the authorize half of `TestMCPRefusesOpenID` (oidc_flavour_test.go:201-218). Their function names stay because `scripts/check-phase19.4.sh` and `scripts/check-phase19.7.sh` require them by name. +- This narrows phase 19.4 D-07 ("MCP clients cannot request openid or receive an id_token") from refusal to dropping. The security intent holds: an MCP client never gets an identity scope or an `id_token`. Do not edit 19.4 planning files; the SUMMARY records the amendment. +- No parity fixture, consumer app or `sm-oauthserver-plugin` doc depends on the refusal message for identity scopes. The plugin is untouched. + +Output: the code change, tests and docs in one framework commit. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/STATE.md +@CLAUDE.md +@modules/wristband/authorize.go +@modules/wristband/mcp_scopes_test.go + + + + + + Task 1: MCP authorize drops openid/profile/email, end to end from query to pending row + modules/wristband/authorize.go, modules/wristband/authorize_test.go, modules/wristband/mcp_scopes_test.go, modules/wristband/oidc_flavour_test.go + modules/wristband/authorize.go (whole file, 295 lines), modules/wristband/authorize_test.go lines 1-135 (helpers insertAuthorizeTestClient, s256Pair, authorizeRequest, queryOf, authorizeTestRedirect, newAuthorizeTestServer, pendingCount) and 443-475 (the read-bogus rejection test, the style to follow), modules/wristband/mcp_scopes_test.go (whole), modules/wristband/oidc_flavour_test.go lines 200-240, modules/wristband/registration_test.go lines 274-283 (newTestServer sets Issuer https://issuer.example.test and Resource https://resource.example.test/mcp) + + - Discovery-reading client, exact query: client_id, redirect_uri=authorizeTestRedirect, response_type=code, scope="openid email read", S256 challenge, resource=srv.opts.Resource, state="xyz", ui_locales="en-US" -> 302; Location starts with srv.opts.Issuer + "/connect?request="; the Location query has no error key; pending count rises by exactly 1; that row's Scopes equal [read], OfflineAccess is false, Resource points at srv.opts.Resource, State points at "xyz" + - scope="openid profile" -> 302 to /connect, row Scopes [read] + - scope="openid" -> 302 to /connect, row Scopes [read] + - scope="openid profile email read write" -> row Scopes [read write] (order preserved) + - Ceiling still applies after the drop: a client with ceiling [read write] sending "openid email read ai" -> row Scopes [read] + - Still refused with invalid_scope / "scope contains an unsupported value" and no new pending row: "openid bogus", "read bogus", "OPENID read" (case-sensitive match, so the uppercase token is unknown) + + +RED first. In modules/wristband/authorize_test.go add TestAuthorizeDropsOIDCIdentityScopes right after TestAuthorizeScopeInvalidValueRedirectsInvalidScope. Make it table-driven, one t.Run per behavior bullet above, each case on a fresh newAuthorizeTestServer with its own client from insertAuthorizeTestClient (nil ceiling, except [read write] for the ceiling case). Read persisted rows under backend.mu as the neighbouring tests do, and compare slices with slices.Equal (add the slices import if the file lacks it). The first case reproduces the discovery-reading client's request verbatim, including resource and ui_locales. Its comment describes it neutrally as "a client that also reads the issuer's OpenID Connect discovery document". No product or application names in comments, test names or strings; the existing authorizeTestRedirect constant stays as it is. Run the test and confirm it fails before touching authorize.go. + +GREEN. In modules/wristband/authorize.go add an unexported package-level set named mcpIgnoredIdentityScopes (a map[string]bool holding openid, profile and email) next to parseAuthorizeScopes. Keep the signature parseAuthorizeScopes(raw string, allowed map[string]bool) ([]string, string). Its new body: split with strings.Fields as now; build a filtered slice that skips every token found in mcpIgnoredIdentityScopes (exact, case-sensitive match, checked before and independently of allowed, so a host listing one of them in Options.ScopesSupported still never gets it persisted); if the filtered slice is empty return the existing [read] default with no error; otherwise validate each remaining token against allowed exactly as today, with the same "scope contains an unsupported value" message, and return the filtered slice. Do not special-case offline_access, do not dedupe, and do not change the Authorize call site, the ceiling block, allowedAuthorizeScopes, resolvedScopes, Metadata or anything in oidc_*.go. Rewrite the parseAuthorizeScopes doc comment so it says, in prose: it ports OAuthAuthorizeController::parseScopes with one deliberate divergence; the identity scopes openid, profile and email are dropped first, because clients that also read the issuer's OpenID Connect discovery document request them on the MCP endpoint, and RFC 6749 section 3.3 lets the server grant fewer scopes than requested; the MCP flavour issues no ID tokens and never grants identity scopes (phase 19.4 D-07, narrowed from refusal to dropping); an empty result falls back to [read]; any other token outside allowed is refused. + +Fix the two tests the drop breaks, keeping both function names (the phase 19.4 and 19.7 gate scripts list them): +- mcp_scopes_test.go TestMCPScopes: rename the subtest to "identity scopes are dropped and unlisted scopes stay refused". On both servers "read openid" now succeeds and "read admin" stays invalid_scope. "openid backend" stays invalid_scope on the plain server (backend is not listed) and succeeds on withBackend; after that call, assert the newest row in that server's memory backend stores exactly [backend]. Update the function's doc comment, which currently says openid stays refused. +- oidc_flavour_test.go TestMCPRefusesOpenID: the loop over "openid", "openid read" and "read openid profile" now asserts a 302 with no error key in the Location query and a newest pending row whose Scopes are exactly [read] (none of the three identity names). Leave the token half (a code stored with openid read, no id_token in the answer) as it is. Update the doc comment: the MCP flavour never grants openid; authorize drops it, and its token answer never carries an id_token. + +Do not commit in this task; Task 2 commits code and docs together. + + + cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/wristband/ && go test -count=1 -short ./modules/wristband/... && OUT=$(go test -count=1 -short -run '^(TestAuthorizeDropsOIDCIdentityScopes|TestAuthorizeScopeInvalidValueRedirectsInvalidScope|TestMCPScopes|TestMCPScopesSmoke|TestMCPRefusesOpenID|TestPhase08Coverage)$' -v ./modules/wristband 2>&1) && MISSING=$(for n in TestAuthorizeDropsOIDCIdentityScopes TestAuthorizeScopeInvalidValueRedirectsInvalidScope TestMCPScopes TestMCPScopesSmoke TestMCPRefusesOpenID TestPhase08Coverage; do grep -q -- "^--- PASS: $n " <<<"$OUT" || echo "$n"; done) && test -z "$MISSING" && git diff --quiet -- modules/wristband/mcp_scopes_smoke_test.go modules/wristband/oidc_authorize.go modules/wristband/server.go modules/wristband/token.go modules/wristband/consent.go + + TestAuthorizeDropsOIDCIdentityScopes failed before the authorize.go change and passes after it. The whole wristband package passes under -short. TestMCPScopesSmoke, the OIDC flavour files, server.go, token.go and consent.go have no diff. The exact discovery-reading client request stores [read] and redirects to /connect. + + + + Task 2: Document the drop in the wristband README and the OAuth server guide, run the gates, commit + modules/wristband/README.md, docs/services/oauth-server.md + modules/wristband/README.md lines 28-40 (Features bullets for the authorization endpoint and client kinds), docs/services/oauth-server.md lines 40-44 and 215-219 + +Update both documents in prose that matches the surrounding style: short declarative sentences, backticked identifiers, no new wristband.* identifiers (the docs checker requires every identifier to exist; parseAuthorizeScopes is unexported, so do not name it), and no application or product names. + +modules/wristband/README.md: +- Authorization endpoint bullet (line 30): just before "Any other value is `invalid_scope`.", add a sentence containing the exact phrase "are dropped from the request". It should say that the OpenID Connect identity scopes `openid`, `profile` and `email` are dropped from the request before that check (RFC 6749 section 3.3 lets a server grant fewer scopes than requested), so a client that also reads the issuer's OpenID Connect discovery document still connects; that a request with nothing else left gets the default `read`; and that the three are never advertised. +- Client kinds bullet (line 39): replace the clause saying the MCP flavour continues to refuse the `openid` scope with one saying that the MCP flavour drops `openid`, `profile` and `email` at authorize and never issues an `id_token`. Keep the rest of the sentence, "so a dynamically registered client never receives identity scopes or an `id_token`", as it is. + +docs/services/oauth-server.md: +- Scope paragraph (line 42): after "...still refuses `backend`.", add the same kind of sentence, also containing "are dropped from the request". Keep the final sentence that OpenID Connect discovery stays `openid`, `profile` and `email`. +- Client kinds paragraph (line 217): make the same replacement as in the README client-kinds bullet, ending "so a dynamically registered client can never obtain identity scopes or an `id_token`." + +Leave docs/services/mcp.md alone: its line 141 only says OIDC discovery stays the three names, which is still true. + +Run the full gates. Then make one commit in summercms.go, staging these six paths explicitly and nothing else (never git add -A; other sessions' STATE.md, state.json and G15OFFICE.md edits stay unstaged): modules/wristband/authorize.go, modules/wristband/authorize_test.go, modules/wristband/mcp_scopes_test.go, modules/wristband/oidc_flavour_test.go, modules/wristband/README.md, docs/services/oauth-server.md. Commit message subject: "fix(wristband): drop OIDC identity scopes at MCP authorize". The body says that requests carrying openid, profile or email (sent by clients that also read the issuer's OIDC discovery document) were refused with invalid_scope, that the three are now dropped per RFC 6749 section 3.3, that any other unknown scope is still refused, that the MCP flavour still never grants identity scopes or issues an id_token, and that metadata is unchanged. No co-author or Claude attribution lines (the user's global rule overrides the attribution reminder). Do not push or tag. + + + cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short ./... && go test -count=1 ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check && grep -q "are dropped from the request" modules/wristband/README.md && grep -q "are dropped from the request" docs/services/oauth-server.md && ! grep -q 'keeps refusing' modules/wristband/README.md docs/services/oauth-server.md && MSG=$(git log -1 --format=%B) && ! grep -qi 'co-authored-by' <<<"$MSG" && FILES=$(git show --name-only --format= HEAD) && test "$(grep -v '^$' <<<"$FILES" | sort | tr '\n' ' ')" = "docs/services/oauth-server.md modules/wristband/README.md modules/wristband/authorize.go modules/wristband/authorize_test.go modules/wristband/mcp_scopes_test.go modules/wristband/oidc_flavour_test.go " + + Both documents describe the drop and no longer say the MCP flavour refuses openid. The docs checker, go vet ./... and go test -short ./... are green. HEAD is a single commit with exactly the six files and no co-author line. Nothing is pushed or tagged. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser/client -> GET /oauth/mcp/authorize | Untrusted query string (client_id, redirect_uri, scope, resource, state) from a dynamically registered MCP client | +| pending row -> /connect consent -> token | Scopes persisted at authorize become the user's consent choices and the issued token's scopes | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-q261106jhk-01 | Elevation of privilege | parseAuthorizeScopes / MCP pending row: an MCP client obtaining an identity scope or an id_token by asking for openid | medium | mitigate | Task 1 drops openid/profile/email unconditionally, before and independently of the allow-list, so they are never persisted even if a host lists them. TestMCPRefusesOpenID asserts the persisted Scopes contain none of the three, and its unchanged token half asserts no id_token in an MCP token answer | +| T-q261106jhk-02 | Elevation of privilege / Tampering | parseAuthorizeScopes: the tolerance accepting other unknown or look-alike scopes | medium | mitigate | Only the three exact, case-sensitive tokens are dropped. "openid bogus", "read bogus", "OPENID read", "read admin" and backend on DefaultOptions stay invalid_scope with no pending row (TestAuthorizeDropsOIDCIdentityScopes, TestMCPScopes, unedited TestAuthorizeScopeInvalidValueRedirectsInvalidScope and TestMCPScopesSmoke) | +| T-q261106jhk-03 | Elevation of privilege | Ceiling bypass through the drop | low | mitigate | The drop happens inside parseAuthorizeScopes, before the unchanged ceiling block; the ceiling case ("openid email read ai" with ceiling [read write] stores [read]) pins the ordering | +| T-q261106jhk-04 | Repudiation / Spoofing | Client assumes it was granted openid | low | accept | RFC 6749 section 3.3 allows a narrower grant; the token answer's scope field already reports the granted scopes (token.go) and the consent page lists only data scopes. The MCP flavour publishes no id_token support | +| T-q261106jhk-05 | Elevation of privilege | An identity-only request falls back to the read default | low | accept | Same as the existing empty-scope default (PHP parity); the user still consents at /connect before any code is issued | +| T-q261106jhk-06 | Tampering | Breaking change in shared wristband for consuming apps and sm-oauthserver-plugin | low | mitigate | No exported identifier, option, metadata field or error message changes; only requests that previously failed now succeed. Full go test -short ./... is green in Task 2 | +| T-q261106jhk-SC | Tampering | npm/pip/cargo/go module installs | low | accept | No new dependencies and no package-manager install task | + + + +- go vet ./... and go test -short ./... green in summercms.go. +- go test -short -v for TestAuthorizeDropsOIDCIdentityScopes, TestAuthorizeScopeInvalidValueRedirectsInvalidScope, TestMCPScopes, TestMCPScopesSmoke, TestMCPRefusesOpenID and TestPhase08Coverage: 6 top-level PASS lines. +- go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check green. +- Metadata scopes_supported unchanged (TestMCPScopesSmoke exact-slice assertions, file has no diff); OIDC flavour files have no diff. +- One commit with exactly the six listed files, no co-author line, not pushed or tagged. + + + +A client that sends scope=openid email read (plus resource and ui_locales) to the MCP authorize endpoint is redirected to /connect with a pending request for [read] instead of getting error=invalid_scope. Identity-only requests default to [read]. Every other unknown scope is still refused with the same error. The MCP flavour still never grants an identity scope or issues an id_token, metadata is unchanged, and the README and the OAuth server guide describe the new behaviour in the same commit. + + + +Create `.planning/quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/261106-jhk-SUMMARY.md` when done. Record the commit hash, the six files, the RED-then-GREEN evidence for TestAuthorizeDropsOIDCIdentityScopes, and a note that phase 19.4 D-07 is narrowed from "MCP clients cannot request openid" to "the MCP flavour drops openid/profile/email and never grants them or issues an id_token". + diff --git a/.planning/quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/261106-jhk-SUMMARY.md b/.planning/quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/261106-jhk-SUMMARY.md new file mode 100644 index 00000000..0abf2489 --- /dev/null +++ b/.planning/quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/261106-jhk-SUMMARY.md @@ -0,0 +1,94 @@ +--- +phase: quick-261106-jhk +plan: 01 +subsystem: wristband +status: complete +tags: [oauth, mcp, scopes, oidc, rfc6749] +requires: [] +provides: + - MCP authorize drops openid/profile/email instead of refusing them +affects: + - modules/wristband (MCP flavour authorize) + - phase 19.4 D-07 (narrowed) +tech-stack: + added: [] + patterns: + - drop-before-validate for a fixed, case-sensitive identity-scope set +key-files: + created: [] + modified: + - modules/wristband/authorize.go + - modules/wristband/authorize_test.go + - modules/wristband/mcp_scopes_test.go + - modules/wristband/oidc_flavour_test.go + - modules/wristband/oidc_authorize_smoke_test.go + - modules/wristband/README.md + - docs/services/oauth-server.md +decisions: + - "MCP authorize drops the OIDC identity scopes openid, profile and email (exact, case-sensitive) before the allow-list check, per RFC 6749 section 3.3; phase 19.4 D-07 narrowed from refusal to dropping; the MCP flavour still never grants identity scopes or issues an id_token" +metrics: + duration: ~15 min + completed: 2026-11-06 +commits: 1 +plan_head_before: 5db032f52f34789399928461ed8024108c7cba04 +plan_head_after: aba678b +actuals: + tokens: 6000 + tasks: 2 + commits: 1 +--- + +# Quick 261106-jhk: MCP authorize drops OIDC identity scopes Summary + +The MCP flavour's `parseAuthorizeScopes` now drops `openid`, `profile` and `email` (exact match, before and independently of the allow-list) so a client that also reads the issuer's OIDC discovery document and sends `scope=openid email read` gets a 302 to `/connect` with a pending `[read]` row instead of `invalid_scope`. + +## Commit + +- `aba678b` fix(wristband): drop OIDC identity scopes at MCP authorize (7 files, code + tests + docs in one commit, on master, not pushed or tagged, no co-author line) + +## What changed + +- `modules/wristband/authorize.go`: new unexported `mcpIgnoredIdentityScopes` set; `parseAuthorizeScopes` filters it out first, an empty remainder falls back to `[read]`, every other token is validated against the allow-list as before with the same message. Doc comment states the PHP divergence, RFC 6749 section 3.3 and D-07. The call site, ceiling block, `allowedAuthorizeScopes`, `resolvedScopes`, Metadata and every `oidc_*.go` source file are unchanged. +- `modules/wristband/authorize_test.go`: `TestAuthorizeDropsOIDCIdentityScopes` (8 table cases: the discovery-reading client's exact request with `resource` and `ui_locales`, identity-only, openid alone, order preserved, ceiling after the drop, and the refused `openid bogus` / `read bogus` / `OPENID read`). +- `modules/wristband/mcp_scopes_test.go`: `TestMCPScopes` subtest renamed to "identity scopes are dropped and unlisted scopes stay refused"; `openid backend` on the listing server stores exactly `[backend]`. +- `modules/wristband/oidc_flavour_test.go`: `TestMCPRefusesOpenID` authorize half now asserts 302 with no error and a stored `[read]`; the id_token token half is unchanged. +- `modules/wristband/README.md`, `docs/services/oauth-server.md`: scope and client-kinds passages describe the drop ("are dropped from the request"); "keeps refusing" wording removed. + +## TDD evidence + +- RED: before the `authorize.go` change, `go test -short -run '^TestAuthorizeDropsOIDCIdentityScopes$' ./modules/wristband` failed in the 5 success cases (each redirected with `error=invalid_scope&error_description=scope%20contains%20an%20unsupported%20value`); the 3 refusal cases passed. +- GREEN: after the change all 8 cases pass. + +## Verification + +- `go vet ./...` green; `go test -short ./...` green. +- `-v` run: PASS for TestAuthorizeDropsOIDCIdentityScopes, TestAuthorizeScopeInvalidValueRedirectsInvalidScope, TestMCPScopes, TestMCPScopesSmoke, TestMCPRefusesOpenID, TestPhase08Coverage, TestKindIsolationSmoke. +- `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` ("no problems found") green. +- No diff in mcp_scopes_smoke_test.go, oidc_authorize.go, server.go, token.go, consent.go (metadata `scopes_supported` unchanged). + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] A third test asserted the old refusal** +- **Found during:** Task 1 (full wristband package run) +- **Issue:** `TestKindIsolationSmoke` in `modules/wristband/oidc_authorize_smoke_test.go` asserted that MCP authorize with `openid read` redirects with `invalid_scope` (the D-07 refusal). The plan listed only two breaking tests. +- **Fix:** The block now asserts a 302 with no error and a stored `[read]` (no identity scope persisted). Function name kept; `scripts/check-phase19.4.sh` lists it. +- **Files modified:** modules/wristband/oidc_authorize_smoke_test.go +- **Commit:** aba678b. As a result the commit carries seven files, not the six the plan's Task 2 verify expects. + +**2. Test fixture detail:** the discovery-reading case sends the literal `https://resource.example.test/mcp` and asserts it equals `srv.opts.Resource` before checking the stored row, rather than reading `srv.opts.Resource` into the request map. Same coverage. + +## Deferred Issues (out of scope, not caused by this change) + +I also ran the phase gate scripts that list the edited tests by name: +- `scripts/check-phase19.4.sh`: all 42 wristband tests (TestKindIsolationSmoke and TestMCPRefusesOpenID included) and all sm-oauthserver-plugin tests pass. The script then fails in its fonoteka.go docker stage on `TestPhase8RedMCPMe` and `TestOAuthToolsMeRouteIsolation`, both reporting `unexpected /me-suffixed route on another surface: GET /_user/api/v1/token/me`. That is a route-surface check against the user plugin's token routes, unrelated to authorize scope parsing. +- `scripts/check-phase19.7.sh`: every go stage passes (TestMCPScopesSmoke and TestMCPScopes included). Its "plugins" stage fails because `../sm-demo-app/plugins/golem15/user` resolves the framework `replace` to the wrong sibling path, a problem in that sibling checkout. + +## Phase 19.4 D-07 amendment + +D-07 is narrowed from "MCP clients cannot request openid" to "the MCP flavour drops openid/profile/email at authorize and never grants them or issues an id_token". 19.4 planning files were not edited. + +## Self-Check: PASSED + +- All seven modified files exist; commit `aba678b` is present in `git log`.