Repository navigation
[TW-7622] MCP-only principal-scoped login, credential isolation and OAuth status - #127
Conversation
…ation and OAuth status `nylas oauth login --mcp-only` requests the MCP scopes without dashboard.session, so the consent screen can offer an IAM principal. The result is stored as its own credential beside the default session, keyed by server, audience, org, user and principal: the same key replaces (and revokes) that credential, anything else is stored alongside it. A restricted token is never exchanged for a dashboard session, and the exchange refuses any token carrying a principal claim before calling the server. - oauth status lists the default session and every MCP-only credential with access mode, audience, org and principal; no token is printed - oauth token/logout --credential, logout --all; config reset clears all - mcp serve/install --auth oauth --credential <id>; without it serve uses the default session, else the only MCP-only credential - refresh retries 503 temporarily_unavailable after Retry-After with backoff and keeps the stored sign-in (contract section 7) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e8019f5 to
967b118
Compare
Review of PR #127 (Claude + Codex)Two reviewers examined this PR: Claude and Codex. We did a check of each finding against the code on Result: the design is good. No item breaks the build or the tests. But some failure paths can lose or orphan credentials. We recommend that you fix findings 1 to 5 before merge. The isolation design is correct:
Summary of the claims
Findings1. [Major] A semantic conflict with #126. Git does not show it. (Claude)
2. [Major]
3. [Major]
4. [Minor] A failed replace-login loses the old credential and leaks the new one. (Claude, reproduced)
5. [Minor] A corrupt index makes
6. [Minor]
7. [Minor] The 503 retry does not operate as the description says. (Claude, reproduced)
8. [Minor]
9. [Minor]
Nits (optional)
Items that we examined and found correct
Build and test results (
|
| Check | Result |
|---|---|
go build ./... |
Pass |
go vet ./... and go vet -tags integration ./... |
Pass |
go test -race -count=1 on the changed packages |
Pass (also with SSH variables set) |
golangci-lint on the changed packages |
0 issues |
TestCLI_OAuthMCPOnly_FakeServerLogin (integration) |
Pass |
🤖 Generated with Claude Code
Second review of PR #127 (Claude + Codex), commit
|
| # | Verdict | Note |
|---|---|---|
| 1. Semantic conflict with #126 | Confirmed, reproduced | Claude made a simple merge of #126 and this PR, and it builds. In that merge, oauth login --mcp-only --json and --mcp-only --quiet exchange the default session for a dashboard session. The JSON says "dashboard":{"signed_in":true} and does not show the credential. No test finds this. |
2. mcp serve selects a default session that cannot reach MCP |
Confirmed, reproduced | TestSelectMCPCredential (credentials_test.go:256-260) tests this behavior on purpose. But the docs recommend the setup that causes the failure. Note: a check of session.Resource != "" is not sufficient. The audience must name a Nylas MCP server. |
3. logout --credential can orphan tokens |
Confirmed, reproduced (both) | 7 keys stay in the keyring, and nothing is revoked. |
| 4. A failed replace-login loses the old credential | Confirmed, reproduced (both) | Also: a corrupt index (credentials.go:222-225) drops a new login and does not revoke it. |
5. A corrupt index makes config reset orphan credentials |
Confirmed, reproduced (both) | 7 keys stay after the second reset. |
6. status --verify does not examine MCP-only credentials |
Confirmed, reproduced | --verify passes for a credential that fails verification. |
| 7a. The CLI retries each 503 | Confirmed, reproduced (both) | The CLI retries an HTML 503 from a proxy 3 times with the same refresh token. |
| 7b. The retry waits ignore Ctrl-C | Confirmed, reproduced | Ctrl-C has no effect. With Retry-After: 20, the CLI makes only 2 attempts in 30s. Also, the CLI holds the session lock during all of the waits. |
8. mcp install --auth oauth removes the pin |
Confirmed, reproduced | The CLI accepts --credential not-an-id. Also: --credential without --auth fails, also when the current install already uses OAuth. |
9. accessMode gives too much access |
Confirmed, reproduced |
Corrections to the first review. Three statements in the first review are not correct:
- "We found no path that puts a token with a principal into the default session or into the dashboard exchange." This is not correct. Refer to new finding 1.
- "
Retry-Afterhandles ... very large values." This is not correct. Refer to the first nit. - "All files have fewer than 600 lines." This is true for all Go files. The largest Go file is
service.gowith 565 lines. Butdocs/COMMANDS.mdhas 1,045 lines. It had 1,006 lines onmain.
New findings
1. [Minor] The principal check does not refuse a token when the claims do not decode. (Claude + Codex, reproduced by both)
- Files:
internal/app/oauthlogin/credentials.go:192,internal/app/dashboard/oauth_session.go:176 - Both checks run only when
domain.DecodeOAuthAccessTokenreturns no error. If the decode fails, the CLI accepts the token. - These tokens contain a principal, but the CLI stores them as the default session and sends them to the exchange:
- A fractional
exp, for example4102444800.5. (Codex) "principal"as a string or as an array. (Claude)- A valid principal object, but
"permissions"as a string, for example"a b". (Claude)
- A fractional
- The last case comes from the new field
Permissions []string(internal/domain/oauth_client.go:94). No code uses this field. But if the server sendspermissionsin a different shape, the decode fails for each token. ThencredentialForandmcpOnlyRecordalso fail, and an MCP-only login cannot complete. - The server still makes the final decision. Thus we do not know if a real server accepts these tokens. But the CLI check does not do what the PR description says.
- Fix:
- Remove the
Permissionsfield, or change its type tojson.RawMessage. - Examine the
principalkey with a separate, tolerant decode (for example, intomap[string]json.RawMessage). - Refuse the token when
principalis present and notnull. - If the claims do not decode, refuse the token. Do not accept it.
- Add tests for each of the token shapes above.
- Remove the
2. [Minor] An empty --credential value selects the default session. (Codex, reproduced)
- Files:
internal/cli/oauth/token.go:26,internal/cli/oauth/helpers.go:60,internal/cli/oauth/logout.go:32,41 - A script can run
nylas oauth token --credential "$id"when$idis empty. Then the CLI prints the token of the default session. nylas oauth logout --credential "$id"with an empty$idlogs out of the default OAuth session and the dashboard session.- An empty value also passes the check that refuses
--credentialtogether with--all. - Fix:
- Use
cmd.Flags().Changed("credential")to find out if the user gave the flag. - If the user gave the flag, validate the id against
^[0-9a-f]{12}$before you select or change a credential.
- Use
3. [Minor] mcp serve --auth oauth does not show the login instruction. (Claude, reproduced)
- File:
internal/cli/mcp/serve.go:154-156 - On
main, when no session is stored, the error tells the user: "Run 'nylas oauth login --for mcp' first". - With this PR, the user sees only
not logged in to the Nylas authorization server. - Cause:
oauthCredentialsreturns the selection error beforebuildOAuthProxyadds the instruction (serve.go:177-179). - Fix: add the same instruction to the selection error.
4. [Minor] The integration test cannot find the regression that it must find. (Claude, reproduced)
- File:
internal/cli/integration/oauth_mcp_only_test.go - The test stores no default session. Thus an incorrect exchange fails locally, and it does not reach the fake server.
- Claude changed the code to exchange after an MCP-only login.
TestCLI_OAuthMCPOnly_FakeServerLoginstill passed. - Also, the
requirecall in the/devicehandler (line 39) runs on a goroutine that is not the test goroutine. - Fix:
- Store a default session first.
- Make sure that the exchange counter and the stored default keys do not change.
- After the rebase on [TW-6922] fix(cli): address remaining OAuth review items (#14, #15, #21, #22, #24, N11) #126, do the same test with
--json. - In the handler, use
t.Errorfor send the error to the test goroutine.
5. [Minor] An unreadable MCP index stops oauth status before it shows the default session. (Claude, code reading)
- File:
internal/cli/oauth/status.go:55-58 - If the CLI cannot read the index,
oauth statusreturns an error. The user does not see the default session. - The index is a JSON array with no version. Thus a format change in a later release can break
oauth statusin an older release. - Fix:
- Show the default session first.
- Then show the index error as a warning.
- Think about a version field in the index.
Nits (optional)
Retry-Afteroverflows before the 30s cap. (Codex, reproduced)client.go:176multiplies beforemin. WithRetry-After: 9223372037, the duration is negative. Then the CLI uses its short fallback wait. Fix: cap the seconds before you multiply.- The hints for an MCP-only credential tell the user to run a plain
nylas oauth login. (Claude) Refer tohelpers.go:180-184andservice.go:398,478. This command makes or replaces the default session. It does not repair the MCP-only credential. The device hint atcredentials.go:194does not include--device. - The selection does not filter credentials by the current server. (Claude) Refer to
credentials.go:119-134. - The credential id is case-sensitive for the host. (Claude + Codex) Thus
API.example.comandapi.example.comgive two different credentials.
We also did a check of these points, and they are correct:
- The keyring key names come only from index ids or a calculated hex id. Thus
--credentialinput cannot change a key name. - The id is deterministic, and it does not change after a refresh.
- The dashboard token source, relogin,
EnsureFreshandorgs switchuse the default session only. - The [TW-7617] Add device-code sign-in for remote CLI login and signup #125 device path sends all tokens through
storeLogin. It revokes a refused token, also after Ctrl-C. - The integration test uses only a local fake server. Thus it does not need
acquireRateLimit.
Merge with #126
git merge-tree pr-126 pr-127 shows text conflicts in login.go and status.go. logout.go merges with no text conflict, but the result is not correct. We recommend that you merge #126 first, and then rebase this PR.
login.go
- Keep
writeLoginReportfrom [TW-6922] fix(cli): address remaining OAuth review items (#14, #15, #21, #22, #24, N11) #126 andloginOptionsForfrom this PR. - Examine
result.Credential != nilbefore the structured-output branch. The exchange must never run for an MCP-only login. This applies to text,--json,--format yamland--quiet. - Add an
mcp_credentialobject to the report: id, audience, org and principal. Never add a token. - For an MCP-only login, report the dashboard as not signed in, and give the reason.
- Keep the device-prompt rule from [TW-6922] fix(cli): address remaining OAuth review items (#14, #15, #21, #22, #24, N11) #126. The URL and the code always show. In structured mode and with
--quiet, they go to stderr.--quiethides only the progress messages. - Add tests for
--mcp-only --jsonand--mcp-only --quiet.
status.go
- Do not keep the early
svc.Status()return from [TW-6922] fix(cli): address remaining OAuth review items (#14, #15, #21, #22, #24, N11) #126. When no default session is stored, the report must still list the MCP-only credentials. - Read the index, then make the report.
- Add the access mode, the principal and a
mcp_credentialslist. Never add a token. - Support
--credentialin structured mode. - Make
--verifyexamine each credential. This also fixes finding 6 of the first review. - Document the exit code for
--quiet. We recommend 0 when one or more credentials are stored.
logout.go
- The
--credentialbranch writes text to stdout before the report branch from [TW-6922] fix(cli): address remaining OAuth review items (#14, #15, #21, #22, #24, N11) #126. Thus--jsonand--quietdo not operate. Add a structured report to this branch. - Make
--allreport the MCP-only credentials that it removed.
helpers.go
- This file merges correctly. The merge result keeps only
WireDashboard()from [TW-6922] fix(cli): address remaining OAuth review items (#14, #15, #21, #22, #24, N11) #126, and noinit(). Make sure thatWireDashboard,dashboardTokenSourceand relogin continue to use the default service.
docs/COMMANDS.md
- Add the MCP fields and the
--quietexit code to the structured-output paragraph from [TW-6922] fix(cli): address remaining OAuth review items (#14, #15, #21, #22, #24, N11) #126.
Build and test results (967b118)
| Check | Result |
|---|---|
go build ./... |
Pass |
go vet ./... and go vet -tags integration ./... |
Pass |
golangci-lint run ./internal/... ./cmd/... |
0 issues |
go test -race -count=1 on the changed packages, SSH variables set |
Pass |
go test -race -count=1 on the changed packages, SSH variables not set |
Pass |
TestCLI_OAuthMCPOnly_FakeServerLogin (integration) |
Pass. It needs no live credentials. It also passes with the incorrect exchange (new finding 4). |
make security |
Pass |
| Simple merge of #126 and this PR | Builds. TestStatusCmd_ListsMCPOnlyCredentialsWithoutTokens fails. |
Both reviewers used scratch tests for the reproductions. We deleted the scratch tests after use.
🤖 Generated with Claude Code
Resolves conflicts with #126 (structured --json/--format yaml/--quiet output): - oauth login: keep loginOptionsFor (--mcp-only) and writeLoginReport. The structured path now skips the dashboard exchange for an MCP-only credential, as the text path does, and reports it under mcp_credential. - oauth status: structured output covers MCP-only credentials (mcp_credentials, --credential) and adds access, token_claims.org and token_claims.principal. - Tests for both; COMMANDS.md documents the new fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jira: TW-7622 (epic TW-7610). Stacked on #125 (TW-7617 device sign-in); retarget to
mainonce that merges.What
nylas oauth login --mcp-only(browser or--device): the--for mcpscopes withoutdashboard.session, so the consent screen can offer an IAM principal. There is no principal flag; the principal is chosen on the consent page.principalclaim before calling the server. A dashboard-capable login whose consent returned a principal is revoked and refused rather than stored as the default session.oauth_mcp_<id>_*), beside the default session and never in place of it. The id is a hash of server, audience, org, user and principal, and a token-free index lists them. The same key replaces that credential and revokes the one it replaces; any other combination is stored alongside, so two principals or regions never overwrite each other.nylas mcp serve --auth oauthuses the default session when present. Otherwise it uses the only MCP-only credential, and with several it requires--credential <id>.mcp install --credentialpins one and preserves it on re-install.oauth statusshows access mode, audience, org and principal for the default session and every MCP-only credential, never a token. Also addsoauth token --credential,oauth logout --credential/--all;config resetclears every credential.temporarily_unavailableafterRetry-After(capped at 30s) with backoff, up to 3 attempts, with the same refresh token, and keeps the stored sign-in (contract §7).Tests
TestCLI_OAuthMCPOnly_FakeServerLogin(device +--mcp-onlyagainst a fake AS: nodashboard.sessionrequested, no exchange call, status andtoken --credential).make fmt vet lint test-unit test-race security buildpasses.make vulnfails on the Go 1.26.5 stdlib pinned ingo.mod, and fails the same way onmain.TestOAuthAS_*skip without one).Notes for review
mcp serveexits with an error rather than choosing one.🤖 Generated with Claude Code