TW-6922: CLI OAuth login, MCP serve over OAuth, and dashboard commands signed in by nylas oauth login - #123
radenkovic wants to merge 18 commits into
Conversation
Stage 1a of integrating the dashboard-account OAuth 2.1 authorization
server into the CLI: the domain types, the port, and the HTTP adapter.
The adapter does not reuse dashboard.AccountClient because these
endpoints are plain RFC 6749/7009/7591 — no house {"data":...} envelope
and no DPoP proof. It resolves every endpoint from the RFC 8414
discovery document rather than assuming a path, which matters because
dashboard-account builds them all from OAUTH_ISSUER and that is a
different host from the local port in a tunnelled dev setup.
PKCE is generated fresh rather than reusing auth.generatePKCEPair: that
helper computes base64std(hex(sha256(v))) for Nylas hosted auth, and the
authorization server enforces /^[A-Za-z0-9\-_]{43}$/, which only the RFC
form satisfies. A test pins the divergence so the two cannot be merged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 1b: the app-layer service that runs the browser authorization code flow and owns the stored session. Reuses the existing loopback callback server and browser adapters unchanged. The client is registered dynamically as a public client the first time it is needed, against the redirect URI http://localhost/callback with no port: RFC 8252 lets the server free the port of a loopback URI at request time, so the ephemeral port the callback server picks still matches. The registration is pinned to the issuer that produced it, because a dev tunnel URL changes between sessions and a client_id does not survive it. Refresh handling is the subtle part. The server rotates the refresh token on every use and burns the whole family if a consumed one reappears, so the service persists exactly what came back and never carries the previous refresh token forward to fill an empty field. A test covers that specific mistake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 1c: the user-facing commands, wired into the root command. Named as its own subtree rather than folded into `nylas auth`, which in this CLI means connecting an end user's mailbox as a provider grant, or `nylas dashboard login`, which opens a dashboard management session. This authenticates the person running the CLI. The authorization server is hosted by dashboard-account, so the base URL resolution is shared rather than duplicated: getDashboardAccountBaseURL is now exported as dashboard.AccountBaseURL and loses a parameter it never read. NYLAS_DASHBOARD_ACCOUNT_URL therefore points both command groups at a local server. `oauth token` prints the bare token so it can be substituted into a curl header, and `oauth status` deliberately never prints the token itself. That file needs `git add -f`: .gitignore has a broad `*token*` rule, and the two token.go files already tracked were added the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 1d: integration tests that run the real adapter against a running dashboard-account, covering discovery, dynamic registration, the full PKCE code exchange, userinfo, refresh rotation, revocation, and the two rejections that matter (replayed code, mismatched verifier). They seed their own user, consent grant and authorization code through the /dev routes. That is what removes the browser from the loop: the consent screen needs a UAS-connected mailbox, which a local stack does not have. The tests front the server with a proxy that rewrites the issuer origin in the discovery document. dashboard-account builds every advertised endpoint from OAUTH_ISSUER, and locally that is often a tunnel hostname that is stale or unreachable, while the client is spec-correct and follows whatever the document says. The proxy is confined to the test — no workaround leaks into the client. Confirmed live, and worth recording: the server really does burn the whole refresh family when a consumed token is replayed, which is the behaviour the storage rules in oauthlogin were written against. Skips unless NYLAS_OAUTH_AS_URL is set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lient The authorization server now registers the CLI once, as the public client b3a94d82-fc7d-4a22-803e-e603ae0f735c with the redirect URIs http://127.0.0.1/callback and http://localhost/callback (any port, RFC 8252 section 7.3). The RFC 7591 registration call, its domain types, the port method, and the per-issuer stored oauth_client_id reuse logic are gone. NYLAS_OAUTH_CLIENT_ID overrides the id for a local or dev server. It is validated against an allow-list, and an invalid override is an error rather than a silent fall back to the default, so a typo is noticed. A keyring entry left by an older build under oauth_client_id is never read and is cleared on the next login or logout. The callback now binds 127.0.0.1 AND advertises http://127.0.0.1:<port>/ callback. The shared callback server advertised "localhost" while binding both loopback families; the server treats localhost and 127.0.0.1 as different hosts, and which family a browser picks for "localhost" is not the CLI's to control. Advertising the literal it listens on makes the two agree by construction. This is a new NewLoopbackIPCallbackServer variant: `nylas auth` still uses the localhost spelling Nylas hosted auth has registered. The service also checks the advertised URI against the two registered shapes before the browser opens, so a mismatch fails closed with a reason instead of on a server error page after sign-in. `nylas oauth status` now decodes the access token and shows its audience, grants, scopes and expiry. The claims are decoded, NOT verified — the output and help say so — and the token itself is never printed; an opaque token is reported as undecodable rather than failing the command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Several `nylas mcp serve` processes — one per assistant — share a single keyring session. The server rotates the refresh token on every use and burns the whole family when a consumed one is replayed, with no grace window, so two of them refreshing at the same moment signed the user out. Every write to the stored session now happens under a cross-process lock: refresh (read, refresh, write), the save at the end of login, and logout, so a refresh cannot write a live session back after logout cleared it. After taking the lock the refresher re-reads the session and, if another process already stored rotated tokens, uses them instead of spending its stale refresh token. The service refuses to refresh with no lock configured rather than falling back to the race. The lock is an advisory lock on oauth-session.lock in the CLI config dir: flock(2) on Unix, LockFileEx on Windows (a build for any other platform fails closed). The kernel drops both when the holder exits, so a crashed process cannot wedge the others — a test kills a holding process to prove it. Acquisition is non-blocking plus polling so it honours the context. Dependency: golang.org/x/sys, which was already in the module graph at v0.43.0 as an indirect dependency and is now marked direct. No new module and no go.sum change. It is the Go team's own syscall package and the standard way to reach flock/LockFileEx; gofrs/flock would have wrapped the same calls and added a module. The concurrency test runs 12 refreshers, each a separate Service with its own lock handle and HTTP client, against a fake token endpoint that revokes the family on replay: exactly one refresh request, all end with the same rotated token. A control runs the same race with the lock bypassed and confirms the family is burned, so the pass is the lock's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`nylas mcp serve --auth oauth` proxies to the hosted MCP server with the session from `nylas oauth login --for mcp`. The default stays the API key, so every existing assistant config keeps working unchanged. The proxy no longer caches an Authorization header. It takes a ports.MCPCredentialSource and asks it before every forwarded request: an access token lives 900 s and a serve process lives as long as its assistant. The OAuth source refreshes near expiry through the TW-7135 locked path, so any number of serve processes share one login. Routing follows the token, not the config: the MCP host is taken from the token's aud, which must name exactly one of https://mcp.us.nylas.com and https://mcp.eu.nylas.com, or the request is not sent. The claims are decoded, not verified — they route the request; the MCP server verifies. RFC 8707: `--for mcp` sends resource=<MCP server of the configured region> on the authorization request and the code exchange, stores it with the session, and repeats it on every refresh, so the audience cannot drift. Unknown resources and regions are refused locally rather than defaulted. `--for mcp` requests email.read, email.send, calendar.read, calendar.write, contacts.read, notetaker.read, grants.read (not grants.write: the tools never create or delete a grant) and offline_access. Scopes the server's discovery document does not list are left out and reported, rather than failing the whole request with invalid_scope. It cannot be combined with --scope. X-Nylas-Grant-Id, and the grant_id the proxy injects into tool calls, are only a hint and only for a grant the token's grants claim lists. In OAuth mode get_grant is not answered from the local grant store, which belongs to the API key's application. Failures say what to do: a 401 with a Bearer challenge refreshes once and retries (once — a revoked session cannot loop); a second refusal, a failed refresh or no session names `nylas oauth login --for mcp`; a 403 insufficient_scope names the missing scope from the challenge. Errors never carry the token. serve also checks the session at start-up so a missing login fails in the assistant's server log, not on the first tool call. Tests drive the proxy against an httptest fake MCP server for each of these, plus integration tests (skipped without NYLAS_OAUTH_AS_URL) for the resource indicator against a live authorization server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Proxy protocol. The hosted server (api-v3 src/mcp/server.go) runs the streamable HTTP handler with Stateless: true, protocol 2026-07-28: every request stands alone and it issues no Mcp-Session-Id. The proxy no longer stores or sends one. It now sends the headers the server reads to route and meter without parsing the body: Mcp-Method on every parsed request, and Mcp-Name for tools/call and prompts/get — the server refuses a Mcp-Name that disagrees with the body. Names are copied into headers only if they match an identifier allow-list; otherwise neither header is sent and the server falls back to the body, as it does for legacy clients. Mcp-Protocol-Version carries the version the server negotiated in its initialize answer (validated as YYYY-MM-DD), not a hard-coded 2026-07-28: the STDIO clients behind the proxy still speak the initialize handshake, and claiming 2026-07-28 over a legacy body would misdescribe the request. initialize is still special-cased only to append timezone guidance. Two existing tests asserted that the proxy stored the server's session id; they now assert it never echoes one, which is the intended change. mcp install. The existing code configures claude-desktop, claude-code, cursor, windsurf and vscode, all as a `nylas mcp serve` STDIO launcher in JSON (mcpServers, or servers for VS Code), and none of them receives a credential. Nothing in this codebase says which of those assistants support a remote MCP server with OAuth, or in what config shape, so none is switched to the hosted URL: the proxy stays the documented compatibility path for all five. `--auth oauth` writes `mcp serve --auth oauth` so the shim uses the OAuth session from the keyring; the default arguments are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- exchange the OAuth access token for a dashboard session at /auth/cli/oauth/exchange after `nylas oauth login`, so `nylas dashboard login` is no longer needed for apps, API keys, domains and orgs - mark that session in the keyring (origin + expiry) and re-exchange a fresh access token about a minute before it expires; the server refuses to refresh it, so refresh on such a session exchanges instead - keep the active app across renewals; reset it only on a new login - clear the markers on `nylas dashboard login`, `config reset` and logout - `nylas oauth logout` also ends a dashboard session it created, and leaves one from `nylas dashboard login` alone - the oauth package registers its login service as the token source, so renewals share the machine-wide refresh lock Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…egion Security review fixes: - bind a stored session to the server it came from (new oauth_server_url key); refresh, userinfo, revoke and every access-token use refuse a different configured server, and logout clears such a session without revoking it there - validate discovery against the base URL: https except on loopback, issuer equal to the base URL (a loopback dev server may advertise an https tunnel), every endpoint on the issuer's scheme and host - follow no redirects on token requests or MCP proxy requests, which carry a code, verifier, refresh token, access token or API key - lock the encrypted file keyring across processes, so a concurrent write can no longer put back a rotated refresh token and burn the family - keep the OAuth refresh lock with the secret store it protects: for the system keyring it no longer follows XDG_CONFIG_HOME - fail on a DPoP key read error instead of replacing the stored key, which the server binds sessions and exchanged tokens to - clear the OAuth session in `nylas config reset` - rebuild the MCP request per attempt, so a retry after renewal carries the renewed token's grant Sign-up region (TW-7142): - `nylas oauth login --region us|eu` (default: the configured region) is sent as `region` on the authorization request; a bad configured region is ignored for a plain login, a bad flag is refused - docs: OAuth login signs in the dashboard commands; where the lock lives Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g switch by re-login - A dashboard session records the account URL and both gateway URLs it was issued for, and is refused (not sent) when any of them changes. Logout clears such a session without contacting the new server. An unrecorded session is adopted, except one exchanged from OAuth whose issuing account server is known to differ. - A refresh finishes even if the caller cancels, since the server has already rotated the token. If the rotated token cannot be stored, the spent one is dropped instead of being replayed. - Session writes put the refresh token before the access token and the expiry last, so a partial write recovers on the next command. The unverified, unused ID token is no longer stored. - The system keyring splits values larger than one keychain item (Windows: 2560 bytes) across several, with a header written last. - `nylas dashboard orgs switch` on an OAuth session signs in again with the same scopes and resource; --org checks the organization chosen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unsent `nylas oauth logout` ends the dashboard session before the OAuth keys, while the OAuth session's server is still stored, so an unrecorded session from another server is cleared locally and never sent to the configured one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deep review: Claude + CodexReviewed at Verdict: the security design is solid. The fixes needed are 1 P1 and 9 P2s, mostly about partial failures and cross-process consistency. There are no P0s. Build and tests:
P11. A failed re-login against a new server leaves the old tokens filed under the new server's nameWhere: What's wrong: Failure scenario:
A scratch test reproduced this: Fix: inside the lock, clear the old session before writing a new one, and clear again if the write fails. if err := s.withSessionLock(ctx, func() error {
if err := s.clearSession(); err != nil { // the new login replaces whatever was there
return err
}
if err := s.saveTokens(metadata.Issuer, opts.Resource, tokens); err != nil {
return errors.Join(err, s.clearSession())
}
return nil
}); err != nil {
return nil, err
}Then correct the Test:
P22.
|
| # | Where | Issue | Fix |
|---|---|---|---|
| 11 | oauthlogin/service.go:~204 |
The code exchange uses what's left of the 5-minute login context, so a slow consent screen leaves it about 1s. | Give ExchangeCode and the dashboard exchange context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second). |
| 12 | domain/oauth.go:218-222, store.go:106-109 |
A token response without expires_in gives a zero ExpiresAt, which IsExpired always treats as expired, so every call refreshes and every MCP request rotates the token. |
Fall back to the JWT exp claim (DecodeOAuthAccessToken) when expires_in is 0. |
| 13 | dashboard/oauth_session.go:90-97 |
The exchange response isn't validated. An empty UserToken becomes nil, which deletes the session while login prints "signed in". A zero ExpiresAt is stored as year 1, so every command re-exchanges. |
Reject a response with an empty UserToken or a zero ExpiresAt. |
| 14 | dashboard/oauth_session.go:119-122 |
isOAuthSession treats a keyring read error as "not OAuth". oauth logout then skips the dashboard clear, and refreshTokens calls account.Refresh, which the server refuses. |
Return (bool, error) and surface the error. |
| 15 | adapters/dpop/dpop.go:42-50; cli/dashboard/helpers.go:121-129 |
A stored seed that is malformed or the wrong length is still overwritten. ClearOAuthSession builds a DPoP service before checking the origin, so oauth logout creates a key when none exists. |
Return ErrDashboardDPoP on a malformed seed, and check isOAuthSession before createDPoPService. |
| 16 | cli/oauth/helpers.go:53-62; cli/dashboard/switch_org.go:128-133 |
Relogin replaces the OAuth session before the dashboard exchange. If the exchange fails, the dashboard stays on the old org, and a later EnsureFresh silently switches it. The --org mismatch error comes after the switch has already happened. |
Exchange first and commit both, or roll back. The mismatch error should say "you are now signed in to X". |
| 17 | adapters/mcp/proxy.go:62,80,429 |
apiKey and injectDefaultGrant are dead in production and only read by tests. The tests at proxy_forward_test.go:211 and proxy_normalize_test.go:502 exercise a path that is never used, one without the AllowsGrantHint check. |
Delete both, and point the tests at injectGrant or send. |
| 18 | adapters/mcp/proxy_auth.go:80 |
The 403 insufficient_scope branch has no p.oauth guard, so API-key users are told to run nylas oauth login. |
Use if p.oauth && resp.StatusCode == http.StatusForbidden && …, and add a test. |
| 19 | adapters/mcp/proxy.go:609-616 |
cloneRPCRequest uses maps.Clone, which is shallow. normalizeListEventsArgs and normalizeAvailabilityArgs still mutate the caller's nested maps, which contradicts the PR claim. |
Deep-copy the nested maps the normalizers touch, or correct the claim. Add a test that the original nested map is unchanged. |
| 20 | adapters/keyring/keyring.go:43-74 |
Unlocked concurrent Sets, or a crash between chunk writes and the header, leave unreachable *.chunk.<gen>.<i> items that can't be enumerated. An older CLI reads a chunked key as the literal nylas:chunked:v1:…. |
Document both. Consider a release note that downgrading after login needs nylas oauth login again. |
| 21 | cli/oauth/helpers.go:44-49 |
dashboard.OAuthTokenSource and OAuthRelogin are wired through init() into package globals, which is hidden coupling. |
Inject them from cmd/nylas/main.go. |
| 22 | cli/oauth/status.go, login.go |
They ignore --json and --quiet (CLAUDE.md: use common.GetOutputWriter(cmd) and common.PrintSuccess). |
Route output through the common writer, at least for status. |
| 23 | cli/mcp/install.go:84-97 |
Re-running mcp install -a X without --auth after an --auth oauth install silently switches back to API-key args. |
Keep the existing auth mode, or print that it changed. |
| 24 | docs | docs/security/overview.md is missing the OAuth keys, the chunk format and .secrets.lock (a security change is CRITICAL in documentation-maintenance.md). docs/COMMANDS.md doesn't show mcp install --auth oauth. ARCHITECTURE.md omits oauthas/, app/oauthlogin/ and cli/oauth/, and calls filelock/ "OAuth refresh" only. |
Update these. |
Lower priority: domain/oauth.go:102-105 lets a loopback base URL advertise endpoints on any https host. It is intentional for dev tunnels, but it means whatever listens on that local port decides where the code, PKCE verifier and refresh token go. Consider requiring an explicit opt-in env var for non-loopback endpoints.
Questions to confirm before release
- MCP server rollout order. API-key users also stop sending
Mcp-Session-Idand start sendingMcp-Method/Mcp-Name/Mcp-Protocol-Version. Ismcp.{us,eu}.nylas.comstateless in production before this CLI release? If not, existingnylas mcp serveusers break. Please add this to "Release order". - Exchange audience.
--for mcpaccess tokens carry the MCP audience and are still sent to/auth/cli/oauth/exchange. Does dashboard-account checkaud, and is accepting them intended?
Verified correct
- PKCE, state and nonce: 32 bytes from
crypto/rand; S256 challenge in raw base64url; state compared in constant time. - Callback server: binds only
127.0.0.1over tcp4. The redirect URI is validated before the browser opens. The success page reflects no input, and error pages usehttp.Error(text/plain, nosniff). - Metadata: https is required except on loopback. The issuer must equal the base URL, and every endpoint must match the issuer's scheme, host and port. Case, trailing-dot, userinfo and port variants all fail closed.
- Redirects: none are followed by the oauthas client, the dashboard account client or the MCP client (
NewNoRedirectClient). - Refresh:
- The lock is held across read, refresh and write, with the session re-read once it is held.
WithoutCancelplus a 30s deadline.- A spent refresh token is deleted only if it is still the stored value.
- The write order is as claimed.
- There is no lock-ordering cycle:
oauth-session.lockis outer and.secrets.lockinner.
- MCP:
- Tokens only go to the two allow-listed MCP hosts.
Mcp-Method/Mcp-Nameare allow-listed to^[A-Za-z0-9_./\-]{1,128}$, so no header injection.- The 401 retry is bounded and only happens on a Bearer challenge.
- No token appears in errors or logs.
- stdout carries only the protocol.
- API key is still the default, and
installnever writes credentials.
- filelock:
- flock is per open-file-description, and Windows uses
LockFileEx. - The wait can be cancelled, and the lock is released when the holding process dies.
- The lock file is 0600 in a 0700 dir.
- The unsupported-platform fallback fails closed.
- flock is per open-file-description, and Windows uses
- Layering: domain imports only the standard library, and app imports only ports and domain.
Suggested order of work
- TW-5028: Add codeowners #1, then [TW-4138] CLI initial version #2 and TW-5027: Demo friendly #3 (reuse one
sessionLockhelper for OAuth and dashboard writes), then TW-4988: update claude #6. These are the correctness and security items. - TW-5026: Add a dev app id #4, TW-4989: Initial Commit Nylas CLI #5 and TW-4986: fix(calendar): fix recurring events API and integration tests #8, which are user-visible behavior and performance.
- TW-4987: fix(air): align cache header test expectations with middleware implementation #7, TW-4985: chore(release): disable automatic homebrew tap updates #9 and TW-4984: docs: add templates.md to INDEX.md #10.
- The P3 table; anything deferred can go to follow-up tickets.
- Re-run
make ci-full, and add the tests listed under each item. Each should fail with its fix reverted, as you did for the hardening commits.
🤖 Generated with Claude Code
…w window Addresses review items #1-#5 on #123: - #1 oauth login clears the stored session under the lock before writing the new one, and again if the write fails, so a failed login against another server never leaves the old refresh token filed under the new server's name. - #2 config reset clears the dashboard and OAuth sessions under their cross-process locks, so an in-flight mcp serve refresh cannot write the session back. - #3 dashboard session renewal, login and clear run under a new dashboard-session.lock and re-check expiry once it is held, so concurrent renewals exchange once and never interleave keys. A separate lock is used because renewal asks the OAuth session for a token, which may take oauth-session.lock; the order is always dashboard, then OAuth, then .secrets.lock. oauth logout holds the dashboard lock across the dashboard clear and the OAuth logout, keeping the dashboard-first order. - #4 the renewer asks for an access token valid for at least the renew window (AccessTokenValidFor), so a re-exchange gets a new expiry. - #5 oauth login keeps an existing `nylas dashboard login` session for the configured server instead of silently replacing it, and says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… abort a login Review item #6 on #123: - Both login flows (oauth login and auth login) set the expected state on the callback server before opening the browser, through a new OAuthServer.SetExpectedState, instead of from the goroutine waiting for the callback. A fast redirect is no longer checked against an unset state. - handleCallback checks state before anything else. A request with a missing or wrong state gets 400 and leaves the login waiting; only the redirect carrying this login's state can end it, with a code or an error. Any page open in the browser could previously abort a login by hitting the fixed port. - The error code is allow-listed to ^[a-z_]{1,64}$ before it reaches the CLI's error text, so escape sequences in the URL never reach the terminal. The callback tests that asserted a wrong-state or state-less request ends the wait now assert the opposite, and cover the real redirect after one. docs/security/overview.md gains the callback rules and the session lock order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-minute access tokens - say that the dashboard session exchange accepts only dashboard-account's built-in first-party clients (the Nylas CLI and Nylas Mail, nylas/dashboard-v3#2776), so a NYLAS_OAUTH_CLIENT_ID override logs in but leaves the dashboard commands signed out - correct the access-token lifetime: the server default is 15 minutes, not one hour Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the upstream call failed, the proxy wrote a JSON-RPC error for every message, including notifications (no "id" member). JSON-RPC 2.0 forbids any reply to a notification; the error came back with "id": null. Seen against prod: notifications/initialized got an error reply when the hosted MCP server refused the OAuth token. A failed notification is now logged to stderr instead. An explicit "id": null is still a request and still answered. The stdio loop moves into serve(ctx, in, out) so a test can drive it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-review: Claude + Codex at
|
| # | Verdict | Notes |
|---|---|---|
| 1 | ✅ OK | oauthlogin/service.go:218-231 clears under the OAuth lock, saves, and clears again on failure. The test can fail. Unlocked readers can still see a mixed session (N2). |
| 2 | ✅ OK (minor gap) | reset.go:113-125 takes the dashboard lock, then the OAuth lock. clearDashboardCredentials (reset.go:128-138) discards every Delete error, so reset can print ✓ while keys remain (N10). |
| 3 | Renew, Login and ClearIfOAuthThen are serialized, and expiry is re-checked under the lock. But only SessionRenewer takes dashboard-session.lock. Every other writer skips it (N3). |
|
| 4 | ✅ OK | AccessTokenValidFor(ctx, oauthSessionRenewBefore), re-checked in refreshLocked. Tests exist. |
| 5 | Correct sequentially. dashboard login doesn't take the lock, so it can store a session while an exchange is in flight, and the exchange then overwrites it (N3). hasDashboardLoginSession also treats a keyring read error as "no session". |
|
| 6 | ✅ OK | State is set before browser.Open in both flows. A bad or missing state gets a 400 without using up once. The error code is allow-listed. There is no goroutine leak. Behaviour change: an error redirect without state now waits out the whole 5 minutes instead of failing fast (N11). |
Lock ordering. Both reviewers traced every path that takes these locks. None takes a lock twice or out of order (dashboard, then OAuth, then .secrets.lock). The locks are not re-entrant: each Lock opens a new descriptor, so a second acquisition from the same process waits until its context expires. Keep that in mind when fixing N3: split out …Locked variants rather than nesting withLock.
New findings
N1 (P1, security invariant) renewRejected hands back a stored session without checking its server
Where: internal/app/dashboard/oauth_session.go:123-127, called from auth_service.go:225-230 · raised by Codex, confirmed
renewRejected returns stored whenever it differs from the rejected token, is from OAuth and is fresh. It never calls checkSessionServer, unlike every other load path (loadDashboardTokens).
Failure scenario:
- A process configured for server A (a dev account URL) loads its session and sends a request.
- Meanwhile another process runs
nylas oauth loginagainst production and stores a fresh production dashboard session. - A's request gets
ErrDashboardSessionExpired, andrenewRejectedreturns the production user and org tokens. - The retry sends them to A.
Fix: load through the same checked path while holding the lock.
err = r.withLock(ctx, func() error {
if isOAuthSession(r.secrets) && !r.needsRenewal() {
u, o, loadErr := loadDashboardTokens(r.secrets, r.server) // enforces checkSessionServer
if loadErr == nil && u != rejected {
userToken, orgToken = u, o
return nil
}
if loadErr != nil && !errors.Is(loadErr, domain.ErrDashboardNotLoggedIn) {
return loadErr // e.g. ErrDashboardServerMismatch: do not exchange over it
}
}
resp, err := r.exchange(ctx, false)
...
})Test: store a fresh OAuth session whose KeyDashboardSessionServer is https://b, call renewRejected on a renewer WithServer("https://a"), and assert it returns ErrDashboardServerMismatch and no token.
N2 (P2, security invariant) Unlocked session reads can mix two servers' sessions and defeat checkIssuer
Where: internal/app/oauthlogin/store.go:48-86 (loadSession), service.go:285-293 (fast path) · raised by Codex, confirmed
loadSession reads the access token first, then issuer, server URL and expiry in separate Gets, without the OAuth lock. Fix #1 made writers clear before saving, but a reader can still see a mixed session.
Failure scenario:
mcp serve, configured for B, reads A's access token.- A concurrent
nylas oauth loginagainst B clears the session and writes B's server URL and expiry. - The reader reads B's server URL, so
checkIssuerpasses, and A's access token goes to B.
The dashboard loader has the same token-then-server order (session_store.go:16-30).
Fix (cheap): read ServerURL/Issuer first, the tokens next, and at the end re-read the access token and server URL. If either changed, retry once or fail with ErrOAuthNotLoggedIn. Alternatively, do the full read under the session lock, which becomes affordable once the credential is cached (#8).
Test: a SecretStore wrapper whose hook runs a different-server Login right after the first KeyOAuthAccessToken read. Assert that AccessToken errors or returns B's token, and never returns A's.
N3 (P2) Most dashboard session writers don't take dashboard-session.lock, which is why fixes #3 and #5 are incomplete
Where: internal/app/dashboard/auth_service.go:137 (Logout), :234 (SwitchOrg), :271 (SyncSessionOrg), :285 (storeTokens), internal/cli/dashboard/apps.go:234 (apps use), internal/cli/setup/wizard_helpers.go:280 · both reviewers
Failure scenarios:
- Login overwrite:
oauth logincheckshasDashboardLoginSession()under the lock and finds none.- It starts the exchange, a network round trip.
nylas dashboard loginin another terminal stores a password/SSO session.replaceSecretValuesoverwrites it. The old session stays live on the server, and the app/org choice is lost. This is exactly what TW-4989: Initial Commit Nylas CLI #5 was meant to prevent.
- Logout resurrection:
dashboard logoutruns whileEnsureFreshis exchanging. Logout clears the keys, then the exchange writes a whole new session back. - Mixed tokens:
replaceSecretValuesis a series of separateSetcalls, so two writers can leave user token A paired with org token B.
Fix:
- Give
AuthServicealock ports.CrossProcessLock, wired fromcommon.DashboardSessionLock(secretStore)incli/dashboard/helpers.go. - Wrap
storeTokens,clearTokens, SwitchOrg/SyncSessionOrg's store step and theapps usewrite in it. SessionRenewer.exchangealready holds the lock and callsAuthService.replaceSecretValues. Split out areplaceSecretValuesLocked(andlogoutLockedforClearIfOAuthThen) so nothing takes the lock twice.- Always take the dashboard lock before anything that can take the OAuth lock.
- Update
docs/security/overview.md, which currently says every dashboard write is serialized.
Test: hold DashboardSessionLock in a goroutine and assert that AuthService.Login (password) and Logout block until it is released, mirroring TestClearSessions_WaitsForARefreshInFlight.
N4 (P2) A failed exchange after oauth login or relogin keeps the previous OAuth dashboard session
Where: internal/app/dashboard/oauth_session.go:146-157, internal/cli/oauth/login.go:117-126, internal/cli/oauth/helpers.go:53-62 · raised by Claude, confirmed
oauthlogin.Login has already replaced the OAuth session when the exchange runs. If the exchange fails, the stored dashboard session with origin=oauth still belongs to the old user or org.
Failure scenario:
nylas dashboard orgs switchsigns in to org B, and the exchange times out. That is likely given TW-4983: feat(http): add custom User-Agent header to all API requests #11's shared 5-minute context.- The user sees an error, but every dashboard command keeps acting on org A for up to 15 minutes, while
oauth statusshows B. - Then
EnsureFreshre-exchanges from B's token and the org changes mid-script.
Fix:
// in SessionRenewer.Login, inside withLock
resp, err = r.exchange(ctx, true)
if err != nil && isOAuthSession(r.secrets) {
// The OAuth session it came from is gone; don't keep acting on it.
return errors.Join(err, NewAuthService(r.account, r.secrets).clearTokens())
}Also fixes #16: check --org before committing, or make the error say "you are now signed in to X".
Test: seed oauthSessionSecrets("old-user"), make ExchangeOAuthTokenFn fail, call Login, and assert KeyDashboardUserToken is gone.
N5 (P2) After a 401 renewal, the retried MCP request goes out without normalization (upgrades #19 to a confirmed bug)
Where: internal/adapters/mcp/proxy.go:296, 537-551, 556-602, 633-640 · raised by Claude and reproduced with an overlay test; Codex found the same
cloneRPCRequest copies only the top-level Arguments map. The first attempt's normalizers therefore mutate the caller's nested get_all_query_parameters / availability_request maps. On the retry the values are already normalized, so modified == false and the original bytes are sent.
Reproduced: list_events with "start": 1700000000. Attempt 1 sends start as a string. After the 401 renewal, attempt 2 sends 1.7e+09 as a float, which the upstream schema rejects. availability rounding is lost the same way.
Fix: deep-copy, or re-parse the pristine bytes for each attempt.
func cloneRPCRequest(req *rpcRequest) (*rpcRequest, error) {
raw, err := json.Marshal(req)
if err != nil { return nil, err }
var out rpcRequest
return &out, json.Unmarshal(raw, &out)
}Test: script a 401 with WWW-Authenticate: Bearer error="invalid_token", then a 200. Send list_events with an integer start, and assert both recorded upstream bodies have "start":"1700000000". Also assert the caller's nested map is unchanged.
N6 (P3) Notifications that are still answered (follow-up to a137da6)
Where: proxy.go:44-52, 151-158, 168-176, 197-202, proxy_response.go:58-68 · both reviewers
The absent-versus-null check in isNotification is correct, but it runs on only one branch:
- Batches, and anything else that doesn't unmarshal into
rpcRequest(e.g.paramsas an array), get a synthetic{"id":null,"error":…}when forwarding fails.TestIsNotification's "not an object → false" row asserts this as correct. - The local
get_granthandler answers an id-lesstools/call. - Rewrites add an id:
rpcRequest.IDhas noomitempty, so wheninjectGrantor the normalizers re-marshal an id-less message, it goes upstream with"id":null, which is a request, and the reply is relayed. - A 200 body the upstream returns for a notification is relayed as-is.
Fix:
- Check
isNotification(line)before every write: the parse-failure branch, the local handler and the success path. - For arrays, drop the failure if every element lacks
id, and otherwise return per-id errors. - Keep the id absent through rewrites, e.g.
ID json.RawMessage \json:"id,omitempty"`(aRawMessagekeepsnull` distinct from absent).
Test: extend TestProxy_FailedNotificationIsNeverAnswered with a batch notification, a params-array notification and an id-less get_grant call, and assert empty stdout. Also assert that a rewritten id-less tools/call reaches the upstream with no id key.
N7 (P3) Untrusted text reaches stderr and the terminal unescaped
Where: proxy.go:184 (log.Printf("… %s …", req.Method, err)), proxy_auth.go:93 (upstream body in the error), oauthas/client.go:140-154 → domain/oauth.go:167 → cli/oauth/helpers.go:154 (error_description) · raised by Codex, confirmed
Escape sequences or CR/LF in a method name, an upstream error body or an authorization-server error_description reach stderr unescaped, which allows terminal and log injection. The callback allow-list from #6 doesn't cover these paths.
Fix: log %q for req.Method, and cap and quote upstream bodies. Filter error_description to printable ASCII, at most 200 characters, before it goes into an error.
Test: feed "\u001b]0;pwned\u0007" through a failed notification and a token-endpoint error, and assert there's no raw \x1b in the output.
N8 (P3) DPoP key creation isn't serialized
Where: internal/cli/dashboard/helpers.go:111 → internal/adapters/dpop/dpop.go:38-62 · raised by Codex, confirmed
Two processes on first use both see no key and generate K1 and K2. The last Set stores K2, but the other process then exchanges and stores a session bound to K1. ClearOAuthSessionThen also creates a key before checking the origin (#15).
Fix: create or load the key while holding the dashboard lock (after N3), or re-read it after Set and use whatever is stored. Check isOAuthSession before createDPoPService in ClearOAuthSessionThen.
Test: two dpop.New calls whose Gets both return not-found before either Set; assert both end up using the stored key.
N9 (P3) ClearIfOAuthThen runs then() twice if releasing the lock fails
Where: oauth_session.go:79-82, 195-205 · raised by Codex, confirmed
withLock returns an unlock error after fn has already run then(). ClearIfOAuthThen treats any error as a failure to take the lock, and calls then() again without the lock. In between, a concurrent login could store a new session, and the second call would log it out.
Fix: track whether fn ran (ran := false; …; ran = true), and only fall back when !ran. Return the unlock error alongside clearErr.
Test: a lock whose unlock returns an error, with a counting then; assert exactly one call.
N10 (P3) config reset reports success when a delete fails
Where: internal/cli/config/reset.go:128-138 · raised by Codex, confirmed
Fix: collect the errors with errors.Join, ignoring domain.ErrSecretNotFound, and return them from clearSessions, so reset doesn't print "✓ cleared" when it failed.
N11 (P3) Smaller items
- The callback now waits on a stateless error redirect (
server.go:189): before TW-4988: update claude #6,?error=access_deniedwith nostatefailed immediately; now it waits the full 5 minutes. That's fine if the authorization server always echoesstateon error redirects (RFC 6749 §4.1.2.1 requires it). Please confirm dashboard-account does, and add a test witherrorplus the correctstate. - Replaced refresh tokens aren't revoked (
service.go:222,Reloginat:249-258): each repeatedoauth loginororgs switchleaves a live refresh-token family on the server. Under the lock, if the old session is for the same server, callclient.Revokeon its refresh token (best effort) before clearing. - An expired
dashboard loginsession blocks the exchange forever (TW-4989: Initial Commit Nylas CLI #5):hasDashboardLoginSessiondoesn't check expiry, so the user has to rundashboard logoutmanually. Say so in the notice. - Tests that can't fail or cover dead code:
proxy_basic_test.go:43asserts the deadapiKeyfield.TestProxy_injectDefaultGranttests a wrapper production never calls. Nothing exercises a retried request after renewal, which is why N5 went unnoticed. - Test files over 600 lines grew further:
account_client_test.go(815→850),proxy_e2e_test.go,auth/service_test.go. Not blocking, but worth splitting while you're in there.
Items #7–#24 from the first review
| # | Status | Evidence / what to do |
|---|---|---|
| 7 | ❌ Not fixed | keyring.go:89-97 fails straight away on a missing chunk. Add the bounded retry from the first review. |
| 8 | ❌ Not fixed | mcp_credentials.go:29-35 → 7 Gets per request. Measured ~185 ms of Argon2id per MCP request on the file store (M-series). Cache the token and its ExpiresAt in MCPCredentials. This also makes N2's locked read affordable. |
| 9 | ❌ Not fixed | Reproduced: with a 0500 config dir, Get → failed to open lock file … permission denied, which breaks every command on a read-only config. Return ErrSecretNotFound if .secrets.enc doesn't exist, before locking. |
| 10 | ❌ Worse | proxy.go is 640 lines. Move send/readResponse/cloneRPCRequest/isNotification into proxy_http.go, and delete #17's dead code. |
| 11 | ❌ Not fixed | service.go:207 and login.go:117 share the 5-minute login context. Use context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) for the code exchange and the dashboard exchange. |
| 12 | ❌ Not fixed | flows.go:96: no expires_in → zero ExpiresAt → refresh on every call. Fall back to the JWT exp. |
| 13 | ❌ Not fixed | account_client.go:168-176 / oauth_session.go:159-167: reject an empty UserToken or a zero ExpiresAt. |
| 14 | ❌ Not fixed | isOAuthSession (oauth_session.go:208-211) should return (bool, error). On error, oauth logout should clear anyway or warn, not print ✓. |
| 15 | 🟡 Partial | Read errors now fail closed ✓. A malformed seed is still overwritten (dpop.go:43-51), and ClearOAuthSessionThen still creates a key first (see N8). |
| 16 | ❌ Not fixed | switch_org.go:124-134: the --org mismatch is detected after both sessions are replaced. See N4. |
| 17 | ❌ Not fixed | Proxy.apiKey (proxy.go:63,81), injectDefaultGrant (:451-458) and sessionLockPath (cli/oauth/helpers.go:116) are all dead in production. Delete them and repoint the tests. |
| 18 | ❌ Not fixed | Reproduced: an API-key proxy getting a 403 insufficient_scope tells the user to run nylas oauth login --for mcp. Add the p.oauth && guard (proxy_auth.go:79). |
| 19 | ❌ Confirmed bug | See N5. |
| 20 | ❌ Not fixed | Orphaned chunks, and older builds reading nylas:chunked:v1:… as the secret itself. At least document both and add a release note. |
| 21 | ❌ Not fixed | cli/oauth/helpers.go:44-49 still wires init() into package globals. Inject them from cmd/nylas/main.go. |
| 22 | ❌ Not fixed | oauth status/login/logout ignore --json/--quiet. Use common.GetOutputWriter(cmd). |
| 23 | ❌ Not fixed | install.go:79: re-running mcp install without --auth silently rewrites an OAuth install back to API key. Keep the existing mode, or print the change. |
| 24 | 🟡 Partial | COMMANDS.md ✓. security/overview.md adds the callback and lock rules, but its key inventory (lines 19-24) doesn't list the OAuth keys, and it overstates dashboard-write serialization (see N3). ARCHITECTURE.md still omits adapters/oauthas, app/oauthlogin, cli/oauth and the new ports. |
Suggested order of work
- N1 and N2, which restore the server-binding invariant. Both fixes are small.
- N3, putting the dashboard lock on every writer (this completes TW-5027: Demo friendly #3 and TW-4989: Initial Commit Nylas CLI #5). Then N4 and TW-5022: fix(email): collapse excessive blank lines in HTML-to-text conversion #16.
- N5 (the deep copy) and TW-4984: docs: add templates.md to INDEX.md #10 (the
proxy.gosplit and TW-4981: feat(email): auto-detect inbox provider for transactional send #17's dead code, done together). - TW-4986: fix(calendar): fix recurring events API and integration tests #8 (credential cache), TW-4985: chore(release): disable automatic homebrew tap updates #9 (read-only config) and TW-4987: fix(air): align cache header test expectations with middleware implementation #7 (chunk retry).
- TW-4983: feat(http): add custom User-Agent header to all API requests #11–TW-5024: fix(auth): update config file when switching default grant #14, TW-4980: feat(email): add GPG/PGP email encryption and decryption #18 and TW-4978: feat(chat): add Slack integration with channel resolution and tools #23, each a small, local change.
- The P3 items (N6–N11, TW-5023: fix: correct Nylas dashboard URL typo #15, TW-4979: docs: streamline README for quick onboarding #20–TW-5019: Nylas Chat #22), and feat(mcp): native MCP server + Codex assistant support #24's docs. Anything deferred should become a follow-up ticket linked from the PR body.
- Run
make ci-full. Mutation-check each new test (it should fail with its fix reverted), as you did for the earlier hardening commits.
The two release questions from the first review are still open: whether the production MCP server is stateless before this CLI ships, and whether /auth/cli/oauth/exchange checks the token's aud. Please answer them in the PR body's "Release order".
🤖 Generated with Claude Code
…person's consent dashboard-account now refuses to exchange an access token for a dashboard session unless its scope carries dashboard.session (nylas/dashboard-v3#2776). The consent screen shows it as "Use the Nylas Dashboard as you, with your full role in this organization". - request dashboard.session by default and with --for mcp - never drop it as "not offered": the server accepts it from the built-in CLI client and deliberately does not list it in scopes_supported - leave it out for a NYLAS_OAUTH_CLIENT_ID override, which the server would refuse the whole request for - add it on relogin (orgs switch), so a session from before keeps working - check the token's scope before the exchange and say "run nylas oauth login again" instead of a bare INVALID_TOKEN; the server still decides - document the scope in COMMANDS.md, mcp.md and `oauth login --help` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
OAuth support in the CLI for the Dashboard OAuth epic (TW-6922). The CLI logs in to the Nylas authorization server in dashboard-account as a static public client with PKCE, uses that session for
nylas mcp serve, and, as of TW-7142, for everynylas dashboardcommand too, sonylas oauth loginis the only login a user needs.Server side: nylas/dashboard-v3#2633.
Changes
TW-6922 — OAuth login
nylas oauth login,status,tokenandlogout, verified against a live authorization serverTW-7133 — Static public client
b3a94d82-fc7d-4a22-803e-e603ae0f735cand its loopback redirect URIs (any port, RFC 8252 §7.3).NYLAS_OAUTH_CLIENT_IDoverrides it for a local or dev server, validated rather than silently ignored.TW-7135 — One refresher per machine
nylas mcp serveprocesses share one keyring session, and the server burns the whole refresh family when a rotated token is replayed. Every write to the stored session now happens under a cross-process lock, so concurrent refreshes no longer sign the user out.TW-7134 —
nylas mcp serve --auth oauthnylas oauth login --for mcp, asking a credential source before every request and refreshing near expiry through the shared lock. The API key stays the default, so existing assistant configs are unchanged.TW-7136 — Stateless MCP protocol; OAuth for
mcp installMcp-Session-Id, and it sends theMcp-Method/Mcp-Namerouting headers.mcp installcan write an OAuth-authenticated config.TW-7142 — Dashboard commands signed in by
nylas oauth loginnylas oauth login, the CLI exchanges the access token at/auth/cli/oauth/exchangefor a DPoP-bound dashboard session, so apps, API keys, domains and orgs work withoutnylas dashboard login.nylas oauth logoutalso ends a dashboard session it created and leaves one fromnylas dashboard loginalone.nylas dashboard loginandconfig resetclear the markers.oauth login, not a failure, because the OAuth login itself succeeded.Hardening after review (ce3bd92)
oauth logoutclears it locally without revoking it on the wrong server.dpop.Newonly generates a key when none is stored; any other keyring read error now fails instead of silently replacing the key..secrets.lock) around every read and write, so two processes can no longer lose each other's writes.~/.config/nylas/oauth-session.lockotherwise.nylas oauth login --region us|eusets the sign-up region. A bad value in the flag is an error; a bad configured region is ignored for a plain login.config resetclears the OAuth session locally (documented as local-only).Session durability and server binding (ec3d285)
nylas dashboard logoutclears it locally without contacting the new server. A session stored before this is adopted by the first server that reads it, except one exchanged from OAuth whose issuing account server is known to differ.nylas dashboard orgs switchon an OAuth session signs in again with the same scopes and resource, and the organization is chosen on the consent page.--orgfails if a different one was chosen.Review fixes #1–#5 (fc7b0e8)
oauth loginclears the stored session under the lock before it writes the new one, and clears it again if the write fails. A failed login against another server can no longer leave the old refresh token filed under the new server's name.config resetclears the dashboard session and the OAuth session under their cross-process locks, so an in-flightmcp serverefresh cannot write the session back.dashboard-session.lockand check expiry again once they hold it. Concurrent renewals therefore exchange once and never interleave keys. Locks are always taken in the same order: dashboard, then OAuth, then.secrets.lock.oauth loginkeeps an existingnylas dashboard loginsession for the configured server instead of silently replacing it, and says so.Callback state checked first, review item #6 (5d38210)
^[a-z_]{1,64}$) before it reaches the CLI's error text.Server contract: first-party clients may exchange (c28253a)
exchangesForDashboardSession, instead of the CLI's id only. The CLI's client id and redirects are unchanged. The scope it now requires is the next section.docs/COMMANDS.mdnow says that with aNYLAS_OAUTH_CLIENT_IDoverride, login succeeds but the dashboard commands stay signed out. It also corrects the access-token lifetime to the server default of 15 minutes (it said one hour).MCP proxy: no reply to a failed notification (a137da6)
notifications/initializedwith a JSON-RPC error ("id": null). JSON-RPC 2.0 forbids any reply to a notification. The failure now goes to stderr. An explicit"id": nullis still a request and is still answered. Mutation-checked.The exchange needs the person's consent:
dashboard.session(3544529)dashboard.session. The consent screen shows it as "Use the Nylas Dashboard as you, with your full role in this organization". Before this, a token consented toopenid email offline_accessbecame a session with the person's full org role.nylas oauth loginrequests it by default, and--for mcprequests it too, so the dashboard commands stay signed in after either. The MCP server ignores it.scopes_supported.NYLAS_OAUTH_CLIENT_IDoverride it is left out and reported, because the server would refuse the whole request.nylas dashboard orgs switchadds it when it signs in again, so a session from before keeps working.dashboard.sessionis missing, it says "runnylas oauth loginagain and allow dashboard access" instead of passing on a bareINVALID_TOKEN. The server still decides.docs/COMMANDS.md,docs/commands/mcp.mdandnylas oauth login --help.Testing
go test ./...passes,golangci-lint runreports 0 issuesAuthorizationheader, DPoP), the renewer (login, skip for dashboard-login sessions, keep a fresh session, re-exchange near expiry, keep the active app),apikeys createon an expired OAuth session renewing first, refresh re-exchanging instead of refreshing, and theoauth login/logoutwiringgo testpasses forinternal/app/dashboard,internal/app/oauthlogin,internal/cli/{oauth,config,dashboard,common},internal/adapters/dashboardandinternal/domain. I did not rerun the full./...suite orgolangci-lint, so CI covers those.07-oauth-cli-session-exchange.spec.tsin #2633)nylas dashboard apps createandnylas dashboard apps apikeys create, all succeededgo test ./internal/...passes andgolangci-lintreports 0 issues on the touched packages. New tests cover:Release order
Deploy dashboard-account with nylas/dashboard-v3#2633 and nylas/dashboard-v3#2776 before releasing this. #2776 now blocks this release: a server without it does not know
dashboard.sessionand refuses the whole authorization request withinvalid_scope, sonylas oauth loginwould fail. No released CLI uses the exchange yet, so nothing in the field breaks when #2776 deploys.Related
🤖 Generated with Claude Code