Skip to content

TW-6922: CLI OAuth login, MCP serve over OAuth, and dashboard commands signed in by nylas oauth login - #123

Open
radenkovic wants to merge 18 commits into
mainfrom
tw-6922-cli-oauth
Open

radenkovic wants to merge 18 commits into
mainfrom
tw-6922-cli-oauth

Conversation

@radenkovic

@radenkovic radenkovic commented Sep 22, 2026 •

Copy link
Copy Markdown

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 every nylas dashboard command too, so nylas oauth login is the only login a user needs.

Server side: nylas/dashboard-v3#2633.

Changes

TW-6922 — OAuth login

  • OAuth authorization server client with RFC 7636 PKCE, and a login service with token rotation
  • nylas oauth login, status, token and logout, verified against a live authorization server

TW-7133 — Static public client

  • Replace dynamic client registration with the built-in public client b3a94d82-fc7d-4a22-803e-e603ae0f735c and its loopback redirect URIs (any port, RFC 8252 §7.3). NYLAS_OAUTH_CLIENT_ID overrides it for a local or dev server, validated rather than silently ignored.

TW-7135 — One refresher per machine

  • Several nylas mcp serve processes 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 oauth

  • Proxies to the hosted MCP server with the session from nylas 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 install

  • The proxy follows the hosted server's stateless protocol: no Mcp-Session-Id, and it sends the Mcp-Method / Mcp-Name routing headers. mcp install can write an OAuth-authenticated config.

TW-7142 — Dashboard commands signed in by nylas oauth login

  • After nylas oauth login, the CLI exchanges the access token at /auth/cli/oauth/exchange for a DPoP-bound dashboard session, so apps, API keys, domains and orgs work without nylas dashboard login.
  • That session is marked in the keyring (origin and expiry). The server refuses to refresh it, so the CLI re-exchanges a fresh access token about a minute before it expires, on every path that loads dashboard tokens. A renewal keeps the active app; a new login resets it.
  • nylas oauth logout also ends a dashboard session it created and leaves one from nylas dashboard login alone. nylas dashboard login and config reset clear the markers.
  • The oauth package registers its login service as the token source, so renewals share the machine-wide refresh lock. A failed exchange is a warning on oauth login, not a failure, because the OAuth login itself succeeded.

Hardening after review (ce3bd92)

  • The server's metadata is checked against the URL the CLI was pointed at: the base URL must be https (loopback excepted), the issuer must match it, and every endpoint must be on the issuer's host. A loopback base may advertise an https tunnel issuer.
  • A stored session is tied to the server that issued it. Pointed at a different server, the CLI refuses to use or refresh it, and oauth logout clears it locally without revoking it on the wrong server.
  • The OAuth client and the MCP proxy no longer follow redirects, so a bearer token or DPoP proof is never replayed to another host.
  • The MCP proxy copies a request's arguments before it adds the grant, so the caller's request is never modified.
  • dpop.New only generates a key when none is stored; any other keyring read error now fails instead of silently replacing the key.
  • The encrypted file keyring takes a cross-process file lock (.secrets.lock) around every read and write, so two processes can no longer lose each other's writes.
  • The session lock sits beside the encrypted file store, or in ~/.config/nylas/oauth-session.lock otherwise.
  • nylas oauth login --region us|eu sets the sign-up region. A bad value in the flag is an error; a bad configured region is ignored for a plain login.
  • config reset clears the OAuth session locally (documented as local-only).

Session durability and server binding (ec3d285)

  • A dashboard session (from either login) records the account URL and both gateway URLs it was issued for. If any of them changes, the session is refused instead of being sent, and nylas dashboard logout clears 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.
  • A refresh finishes on its own 30s deadline even if the caller cancels, because the server has already rotated the token. If the rotated token cannot be stored, a still-stored spent refresh token is deleted rather than replayed.
  • Session writes go server/resource, refresh token, access token, then expiry, so a partial write reads as expired and recovers with the new refresh token. The ID token is no longer stored (nothing verified or read it).
  • The system keyring splits a value larger than one keychain item (Windows: 2560 bytes) into chunks under a fresh generation id, writing the header last.
  • nylas dashboard orgs switch on an OAuth session signs in again with the same scopes and resource, and the organization is chosen on the consent page. --org fails if a different one was chosen.

Review fixes #1–#5 (fc7b0e8)

  • oauth login clears 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 reset clears the dashboard session and the OAuth session under their cross-process locks, so an in-flight mcp serve refresh cannot write the session back.
  • Dashboard session renewal, login and clear run under a new dashboard-session.lock and 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.
  • The renewer asks for an access token that is valid for at least the renew window, so a re-exchange gets a new expiry.
  • oauth login keeps an existing nylas dashboard login session for the configured server instead of silently replacing it, and says so.

Callback state checked first, review item #6 (5d38210)

  • Both login flows set the expected state on the callback server before they open the browser. A fast redirect is no longer checked against an unset state.
  • A callback with a missing or wrong state gets a 400, and the login keeps waiting. Previously, any page open in the browser could abort a login by hitting the fixed port.
  • The error code is allow-listed (^[a-z_]{1,64}$) before it reaches the CLI's error text.

Server contract: first-party clients may exchange (c28253a)

  • nylas/dashboard-v3#2776 adds a second built-in client, Nylas Mail. It also changes the exchange to accept tokens from built-in first-party clients marked 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.md now says that with a NYLAS_OAUTH_CLIENT_ID override, 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)

  • Found testing against prod: when the hosted MCP server refused the token, the proxy answered notifications/initialized with 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": null is still a request and is still answered. Mutation-checked.

The exchange needs the person's consent: dashboard.session (3544529)

  • nylas/dashboard-v3#2776 now refuses the exchange unless the token's scope carries 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 to openid email offline_access became a session with the person's full org role.
  • nylas oauth login requests it by default, and --for mcp requests it too, so the dashboard commands stay signed in after either. The MCP server ignores it.
  • It is never dropped as "not offered". The server accepts it only from the built-in CLI client and deliberately does not list it in scopes_supported.
  • With a NYLAS_OAUTH_CLIENT_ID override it is left out and reported, because the server would refuse the whole request.
  • nylas dashboard orgs switch adds it when it signs in again, so a session from before keeps working.
  • Before the exchange, the renewer reads the token's (unverified) scope. If dashboard.session is missing, it says "run nylas oauth login again and allow dashboard access" instead of passing on a bare INVALID_TOKEN. The server still decides.
  • Updated docs/COMMANDS.md, docs/commands/mcp.md and nylas oauth login --help.

Testing

  • go test ./... passes, golangci-lint run reports 0 issues
  • New unit tests for every hardening item. The MCP fixes and the file lock were mutation-checked: each test fails with its fix reverted.
  • Earlier unit tests: the exchange adapter (path, body, no Authorization header, DPoP), the renewer (login, skip for dashboard-login sessions, keep a fresh session, re-exchange near expiry, keep the active app), apikeys create on an expired OAuth session renewing first, refresh re-exchanging instead of refreshing, and the oauth login / logout wiring
  • After fc7b0e8, 5d38210 and c28253a: go test passes for internal/app/dashboard, internal/app/oauthlogin, internal/cli/{oauth,config,dashboard,common}, internal/adapters/dashboard and internal/domain. I did not rerun the full ./... suite or golangci-lint, so CI covers those.
  • The server half of the exchange passes its e2e spec against a live local stack (07-oauth-cli-session-exchange.spec.ts in #2633)
  • Manual, against a local stack: the built binary through a real browser consent, then nylas dashboard apps create and nylas dashboard apps apikeys create, all succeeded
  • Manual: a session issued by localhost is refused when the CLI is pointed at production, and nothing is sent
  • After 3544529: go test ./internal/... passes and golangci-lint reports 0 issues on the touched packages. New tests cover:
    • the default and MCP scope lists;
    • the first-party exemption from discovery filtering (mutation-checked);
    • the client-id override leaving the scope out;
    • relogin adding the scope without duplicating it;
    • the renewer refusing a token without the scope before asking the server.
  • Not yet run against a live stack. It needs nylas/dashboard-v3#2776 running locally.
  • New tests for the server binding, refresh durability, write order, keyring chunking and the org switch. The server check, the cancellation fix, the spent-token drop and the write order were mutation-checked

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.session and refuses the whole authorization request with invalid_scope, so nylas oauth login would fail. No released CLI uses the exchange yet, so nothing in the field breaks when #2776 deploys.

Related

  • TW-6922 (epic), TW-7133, TW-7134, TW-7135, TW-7136, TW-7142, TW-7426
  • nylas/dashboard-v3#2633, nylas/dashboard-v3#2776

🤖 Generated with Claude Code

radenkovic and others added 10 commits September 21, 2026 14:34
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>
@radenkovic radenkovic changed the title TW-6922: Add CLI OAuth login (PKCE) TW-6922: CLI OAuth login, MCP serve over OAuth, and dashboard commands signed in by nylas oauth login Sep 24, 2026
radenkovic and others added 3 commits September 28, 2026 13:03
…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>
@qasim-nylas

Copy link
Copy Markdown
Collaborator

Deep review: Claude + Codex

Reviewed at ec3d285. Three independent passes: Claude on the OAuth / dashboard-session core, Claude on MCP proxy / keyring / file locks, and Codex across the whole diff. Every finding below was re-checked against the code; one Codex finding (orphaned chunks when a chunk write fails) was a false positive and has been dropped, since chunkHeader(gen, i) already deletes exactly chunks 0..i-1.

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:

  • go build, go vet and golangci-lint are clean, natively and with GOOS=windows.
  • go test -race passes on every touched package.
  • Integration tests compile with -tags integration.
Sev # Area Summary
P1 1 oauthlogin A failed re-login against a new server leaves the old server's refresh token filed under the new server's name
P2 2 config reset config reset clears the OAuth and dashboard sessions without the session lock
P2 3 dashboard Renewing the dashboard OAuth session is not serialized across processes, so the multi-key write can interleave
P2 4 dashboard The renew-before window (60s) is longer than the access-token leeway (30s), so re-exchanges do nothing
P2 5 cli/oauth oauth login silently replaces and orphans an existing dashboard login session
P2 6 callback server The expected state is set asynchronously, and any stray or mismatched callback aborts the login
P2 7 keyring A chunked keychain read that races a refresh fails and tells the user to sign in again
P2 8 mcp Every MCP request reloads 7–9 secrets, which is very expensive on the encrypted file store
P2 9 keyring Encrypted-store Get now needs a writable config dir
P2 10 rules proxy.go is 616 lines, over the 600-line limit in CLAUDE.md
P3 11–24 various See below

P1

1. A failed re-login against a new server leaves the old tokens filed under the new server's name

Where: internal/app/oauthlogin/store.go:91-137 (saveTokens) and internal/app/oauthlogin/service.go:215-217 (Login)

What's wrong: saveTokens writes KeyOAuthIssuer and KeyOAuthServerURL first, then scope, then the refresh token. The doc comment says a later failure is safe because loadSessionForServer will refuse the old tokens. That isn't true: the stored server URL now equals the configured one, so checkIssuer passes. Login doesn't clean up when saveTokens fails.

Failure scenario:

  1. The user is logged in to server A.
  2. They set NYLAS_DASHBOARD_ACCOUNT_URL to server B and run nylas oauth login.
  3. One keychain write after the server URL fails (the user clicks Deny on the macOS prompt, or hits a size limit).
  4. The next command sends A's refresh token to B's token endpoint, and A's access token to B's userinfo endpoint, dashboard exchange and MCP routing.

A scratch test reproduced this: refreshCalls=[<A's refresh token>] after ServerURLValue changed to B and the refresh-token write failed.

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 saveTokens comment. The write order still matters for refresh, but it no longer protects a server switch.

Test:

  • Log in as A, then set f.client.ServerURLValue = "https://b" and make writes to KeyOAuthRefreshToken fail.
  • Login must return an error.
  • Heal the store, advance the clock and call AccessToken. Expect ErrOAuthNotLoggedIn and len(RefreshCalls) == 0.

P2

2. config reset clears the OAuth and dashboard sessions without the session lock

Where: internal/cli/config/reset.go:66-74 and internal/app/oauthlogin/store.go:146

What's wrong: Logout takes the session lock specifically so an in-flight refresh can't write a rotated session back after the clear. config reset calls oauthlogin.ClearSession(secretStore) and clearDashboardCredentials(secretStore) with no lock.

Failure scenario: a nylas mcp serve --auth oauth process run by Claude Desktop or Cursor is mid-refresh. Reset prints "✓ OAuth session cleared", then the proxy stores the rotated tokens and the session is back.

Fix:

  • Export a locked local clear, for example oauthlogin.ClearSessionLocked(ctx, secrets) or a method on Service that wraps ClearSession in withSessionLock, and use it from reset.go.
  • Clear the dashboard OAuth session in the same critical section (see TW-5027: Demo friendly #3).

Test: hold the session lock from a goroutine that writes a refresh token after reset starts. Once reset returns, no OAuth key may remain.

3. Renewing the dashboard OAuth session is not serialized across processes

Where: internal/app/dashboard/oauth_session.go:64-74, 77-105 and internal/app/dashboard/auth_service.go (replaceSecretValues)

What's wrong: EnsureFresh → exchange → replaceSecretValues writes 7–9 keys with no cross-process lock. The file store only locks each individual Get/Set. The PR says every session write is locked, but that only covers the OAuth keys.

Failure scenario:

  • Two nylas dashboard … commands, or a command and oauth login, both see a near-expiry session.
  • Both exchange, and their Set calls interleave.
  • The stored user_token comes from exchange A while org_token and expiry come from B.
  • The rollback in replaceSecretValues can also restore stale values over the other process's write.
  • A related case (Codex): oauth logout clears the dashboard session before it takes the OAuth lock, so a concurrent login can store a fresh dashboard session after logout reports success.

Fix:

  • Run the whole exchange-and-store under the same ports.Lock used for the OAuth session. Reuse sessionLock(secrets).
  • Once the lock is held, re-check EnsureFresh's expiry, so a process that waited uses the session the first one stored instead of exchanging again.
  • Take the same lock for ClearIfOAuth.
  • In logout.go, do the dashboard clear and svc.Logout under one lock hold, or at least clear the dashboard session after the OAuth tokens are revoked.

Test: run two EnsureFresh calls concurrently against a fake account client that returns distinct tokens. Assert that one exchange happens and that the stored user token, org token and expiry all come from it.

4. The renew-before window is longer than the token leeway, so re-exchanges do nothing

Where: internal/app/dashboard/oauth_session.go:23 (oauthSessionRenewBefore = time.Minute) and internal/domain/oauth.go:213 (oauthExpiryLeeway = 30 * time.Second)

What's wrong: the dashboard session ends when the OAuth access token does. Between T-60s and T-30s, EnsureFresh re-exchanges, but AccessToken() still returns the same token because it isn't within 30s of expiry yet.

Consequences:

  • Every dashboard command in that window pays an extra exchange round trip.
  • Each exchange leaves another live server-side session that is never revoked.
  • The command still starts with under 60 seconds left, which is the case the constant exists to prevent.

Fix, either:

  • (a) Let the token source guarantee a minimum remaining lifetime, e.g. AccessTokenValidFor(ctx, min time.Duration) on the OAuthAccessTokens port. oauthlogin refreshes when now+min >= ExpiresAt, and EnsureFresh passes oauthSessionRenewBefore.
  • (b) Make oauthExpiryLeeway >= oauthSessionRenewBefore, for example 2 minutes for both.

(a) is cleaner.

Test: the fake token expires at T+45s, and the exchange returns that expiry. EnsureFresh must make the token source refresh, and the stored expiry must move past T+45s.

5. nylas oauth login silently replaces an existing nylas dashboard login session

Where: internal/cli/oauth/login.go:~115 (exchangeDashboardSessionFn) and internal/app/dashboard/oauth_session.go:58-60, 90-103

What's wrong: every OAuth login, including --for mcp, exchanges and overwrites the dashboard session. It clears the active app and region, and never revokes the replaced password/SSO session. The logout help says a dashboard login session "is left alone", but login doesn't leave it alone.

Failure scenario:

  1. A dashboard login user runs nylas oauth login --for mcp just to set up MCP.
  2. Their dashboard org and app selection change without notice, and the old session stays live on the server.
  3. A later oauth logout then ends the new dashboard session, so they are logged out of the dashboard.

Fix: in SessionRenewer.Login, if a session is stored with no oauth origin, either:

  • skip the exchange and print "Dashboard commands stay signed in via nylas dashboard login", or
  • revoke it first with NewAuthService(...).WithServer(r.server).Logout(ctx) and say so.

I'd also skip the exchange for --for mcp, or confirm the server intends to accept MCP-audience tokens at /auth/cli/oauth/exchange and checks aud (see the questions at the end).

Test: store a dashboard session with no origin key. Either Login leaves KeyDashboardUserToken and KeyDashboardAppID unchanged, or it revokes the old session and prints a notice.

6. Callback server: the expected state is set asynchronously, and any stray callback aborts the login

Where: internal/app/oauthlogin/service.go:188-197 and internal/adapters/oauth/server.go:153-196

What's wrong:

  • setExpectedState runs inside the WaitForCallback goroutine, and nothing orders it before browser.Open(authURL).
  • handleCallback calls s.once.Do(...) on error and state-mismatch paths too. So the first request to /callback, whether it has ?error=…, no state, or a wrong state, uses up the single allowed callback. The real redirect is then ignored.
  • The default port is fixed (9007), so any page open in the user's browser during a login can abort it.
  • The error query value is put into the CLI error text unfiltered, so ANSI escape sequences reach the terminal.

Fix:

  • Set the expected state synchronously before opening the browser. Add SetExpectedState(state) to the callback-server port, or pass state to Start, and call it before browser.Open.
  • In handleCallback, check state first. Answer a missing or mismatched state with 400 without calling once.Do. Only a request with a matching state may resolve the wait, as a code or as an error.
  • Allow-list error to ^[a-z_]{1,64}$ before it goes into the error message (and error_description, if you add it, to printable ASCII).

Tests:

  • A wrong-state request followed by the right one still returns the code.
  • ?error=%1b[31m with no state does not end WaitForCallback.
  • A fake Browser.Open that fires the callback synchronously still succeeds.

7. A chunked keychain read that races a refresh fails and tells the user to sign in again

Where: internal/adapters/keyring/keyring.go:79-98 (Get) and :72 (deleteChunks(key, previous) in Set)

What's wrong:

  • Get reads the header, then each chunk, and fails on any missing chunk.
  • Set deletes the previous generation's chunks right after it writes the new header.
  • AccessToken's fast path (service.go:266-275) reads without the session lock before every MCP request.
  • Chunking starts at 2000 bytes on every OS, so a JWT carrying grants is chunked on macOS and Linux too, not only Windows.

Failure scenario:

  1. Two mcp serve processes are running. A refreshes under the lock.
  2. B reads header H0. A writes H1, then deletes H0's chunks.
  3. B's chunk read returns NotFound, and the tool call fails with "Run nylas oauth login --for mcp to sign in again".

On macOS every item read launches a security process, which widens the window.

Fix: retry a bounded number of times when the header changed under the read.

for attempt := 0; attempt < 3; attempt++ {
    header, err := keyring.Get(serviceName, key) // map ErrNotFound → domain.ErrSecretNotFound
    if err != nil { ... }
    gen, n, ok := parseChunkHeader(header)
    if !ok { return header, nil }
    if v, err := readChunks(key, gen, n); err == nil { return v, nil }
    if again, _ := keyring.Get(serviceName, key); again == header {
        return "", fmt.Errorf("secret %s is incomplete in the system keyring", key)
    }
}

To make this testable, route keyring.Get/Set/Delete through package-level vars.

Test: a stubbed Get runs Set(key, big2) after the header read but before the first chunk read. Get must return big2, not an error.

8. Every MCP request reloads 7–9 secrets

Where: internal/app/oauthlogin/mcp_credentials.go:29 → service.go:266 (AccessToken → loadSessionForServer), called per request from internal/adapters/mcp/proxy.go:~201

What's wrong: each Credential() call re-reads the full session.

  • On the encrypted file store (headless Linux, WSL, containers, NYLAS_DISABLE_KEYRING), each Get takes the exclusive .secrets.lock and derives an Argon2id key (64 MiB, t=3). Seven derivations measured about 210 ms on an M-series Mac; slower hosts pay 0.5–1 s of CPU and 64 MiB allocations per tool call.
  • On macOS it is about 10 security process launches per request.
  • All mcp serve processes serialize on the lock.

Fix: cache the last credential in MCPCredentials, behind a mutex, together with its ExpiresAt. Return the cached token while now + leeway < ExpiresAt, and go to the store only when it is near expiry or in Renew. This stays correct across processes because Renew and refreshLocked already re-read under the lock, and a 401 still triggers Renew.

Test: with a counting SecretStore, two consecutive Credential() calls on an unexpired token make at most one store read.

9. Encrypted-store Get now needs a writable config dir

Where: internal/adapters/keyring/file.go:109-122, 155-160 and internal/adapters/filelock/filelock.go:36-43

What's wrong: Get always runs MkdirAll and OpenFile(O_CREATE) on .secrets.lock. NewSecretStore probes fileStore.Get(KeyAPIKey) (keyring.go:~214-220). With ~/.config/nylas read-only (a Nix/home-manager symlink or a read-only container mount), that probe now returns ErrSecretStoreFailed instead of ErrSecretNotFound, so every command that opens the secret store fails. Keyring users also get a stray .secrets.lock created.

Fix:

  • At the top of Get, if os.Stat(f.path) reports not-exist, return domain.ErrSecretNotFound without locking. Racing a first writer is equivalent to reading just before it.
  • Optionally, on EACCES/EROFS, fall back to an O_RDONLY open of an existing lock file.

Test: with a chmod 0500 config dir and no .secrets.enc, Get returns ErrSecretNotFound, and NewSecretStore with the mock keyring succeeds.

10. proxy.go is over the project's 600-line limit

Where: internal/adapters/mcp/proxy.go goes from 533 to 616 lines. CLAUDE.md says "NEVER create files >600 lines".

Fix: move send, readResponse and cloneRPCRequest into proxy_auth.go or a new proxy_http.go, and delete the dead code in #17.


P3 (fix now, or open follow-ups)

# 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

  1. MCP server rollout order. API-key users also stop sending Mcp-Session-Id and start sending Mcp-Method / Mcp-Name / Mcp-Protocol-Version. Is mcp.{us,eu}.nylas.com stateless in production before this CLI release? If not, existing nylas mcp serve users break. Please add this to "Release order".
  2. Exchange audience. --for mcp access tokens carry the MCP audience and are still sent to /auth/cli/oauth/exchange. Does dashboard-account check aud, 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.1 over tcp4. The redirect URI is validated before the browser opens. The success page reflects no input, and error pages use http.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.
    • WithoutCancel plus 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.lock is outer and .secrets.lock inner.
  • MCP:
    • Tokens only go to the two allow-listed MCP hosts.
    • Mcp-Method / Mcp-Name are 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 install never 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.
  • Layering: domain imports only the standard library, and app imports only ports and domain.

Suggested order of work

  1. TW-5028: Add codeowners #1, then [TW-4138] CLI initial version #2 and TW-5027: Demo friendly #3 (reuse one sessionLock helper for OAuth and dashboard writes), then TW-4988: update claude #6. These are the correctness and security items.
  2. 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.
  3. 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.
  4. The P3 table; anything deferred can go to follow-up tickets.
  5. 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

AaronDDM
AaronDDM previously approved these changes Sep 28, 2026
radenkovic and others added 3 commits September 28, 2026 18:46
…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>
@qasim-nylas

Copy link
Copy Markdown
Collaborator

Re-review: Claude + Codex at a137da6

This follows up the earlier review at ec3d285. It covers the new commits 3819b50, fc7b0e8, 5d38210, c28253a and a137da6. Claude and Codex each reviewed the full diff independently. I checked every finding below against the code; where only one reviewer raised something, it says so.

Verdict: good progress, but it isn't ready to merge.

Build and tests (Claude run; the Codex sandbox could not compile):

  • go build ./..., go vet ./... and GOOS=windows go build ./... pass.
  • go test -race -count=1 passes on all 16 touched packages.
  • golangci-lint reports 0 issues.
  • ❌ internal/adapters/mcp/proxy.go is now 640 lines (533 on main; the CLAUDE.md limit is 600).

Fix verification (#1–#6)

# 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 ⚠️ Incomplete 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 ⚠️ Incomplete 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:

  1. A process configured for server A (a dev account URL) loads its session and sends a request.
  2. Meanwhile another process runs nylas oauth login against production and stores a fresh production dashboard session.
  3. A's request gets ErrDashboardSessionExpired, and renewRejected returns the production user and org tokens.
  4. 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:

  1. mcp serve, configured for B, reads A's access token.
  2. A concurrent nylas oauth login against B clears the session and writes B's server URL and expiry.
  3. The reader reads B's server URL, so checkIssuer passes, 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:
    1. oauth login checks hasDashboardLoginSession() under the lock and finds none.
    2. It starts the exchange, a network round trip.
    3. nylas dashboard login in another terminal stores a password/SSO session.
    4. replaceSecretValues overwrites 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 logout runs while EnsureFresh is exchanging. Logout clears the keys, then the exchange writes a whole new session back.
  • Mixed tokens: replaceSecretValues is a series of separate Set calls, so two writers can leave user token A paired with org token B.

Fix:

  • Give AuthService a lock ports.CrossProcessLock, wired from common.DashboardSessionLock(secretStore) in cli/dashboard/helpers.go.
  • Wrap storeTokens, clearTokens, SwitchOrg/SyncSessionOrg's store step and the apps use write in it.
  • SessionRenewer.exchange already holds the lock and calls AuthService.replaceSecretValues. Split out a replaceSecretValuesLocked (and logoutLocked for ClearIfOAuthThen) 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:

  1. nylas dashboard orgs switch signs 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.
  2. The user sees an error, but every dashboard command keeps acting on org A for up to 15 minutes, while oauth status shows B.
  3. Then EnsureFresh re-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. params as 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_grant handler answers an id-less tools/call.
  • Rewrites add an id: rpcRequest.ID has no omitempty, so when injectGrant or 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_denied with no state failed immediately; now it waits the full 5 minutes. That's fine if the authorization server always echoes state on error redirects (RFC 6749 §4.1.2.1 requires it). Please confirm dashboard-account does, and add a test with error plus the correct state.
  • Replaced refresh tokens aren't revoked (service.go:222, Relogin at :249-258): each repeated oauth login or orgs switch leaves a live refresh-token family on the server. Under the lock, if the old session is for the same server, call client.Revoke on its refresh token (best effort) before clearing.
  • An expired dashboard login session blocks the exchange forever (TW-4989: Initial Commit Nylas CLI #5): hasDashboardLoginSession doesn't check expiry, so the user has to run dashboard logout manually. Say so in the notice.
  • Tests that can't fail or cover dead code: proxy_basic_test.go:43 asserts the dead apiKey field. TestProxy_injectDefaultGrant tests 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

  1. N1 and N2, which restore the server-binding invariant. Both fixes are small.
  2. 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.
  3. N5 (the deep copy) and TW-4984: docs: add templates.md to INDEX.md #10 (the proxy.go split and TW-4981: feat(email): auto-detect inbox provider for transactional send #17's dead code, done together).
  4. 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).
  5. 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.
  6. 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.
  7. 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants