Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9dcc928
TW-6922: add OAuth authorization server client and RFC 7636 PKCE
radenkovic Sep 21, 2026
e076b25
TW-6922: add OAuth login service with PKCE flow and token rotation
radenkovic Sep 21, 2026
f55fcc2
TW-6922: add nylas oauth login, status, token and logout commands
radenkovic Sep 21, 2026
fda95a0
TW-6922: verify the OAuth client against a live authorization server
radenkovic Sep 21, 2026
00f81ea
TW-6922: use plain background on OAuth success page
radenkovic Sep 22, 2026
b8fa3ca
TW-7133: replace dynamic client registration with the static public c…
radenkovic Sep 23, 2026
3b9ff03
TW-7135: serialise OAuth token refresh across processes
radenkovic Sep 23, 2026
348ac60
TW-7134: let nylas mcp serve authenticate with an OAuth session
radenkovic Sep 23, 2026
999dadb
TW-7136: stateless MCP protocol headers; OAuth option for mcp install
radenkovic Sep 23, 2026
add9776
TW-7142: sign the dashboard commands in with nylas oauth login
radenkovic Sep 24, 2026
ce3bd92
TW-6922: harden the OAuth client and session storage; add a sign-up r…
radenkovic Sep 28, 2026
ec3d285
TW-6922: tie dashboard sessions to their servers; durable refresh; or…
radenkovic Sep 28, 2026
3819b50
TW-6922: test that oauth logout drops an old local dashboard session …
radenkovic Sep 28, 2026
fc7b0e8
TW-6922: review fixes: session replacement, cross-process locks, rene…
radenkovic Sep 28, 2026
5d38210
TW-6922: callback server checks state first; stray requests no longer…
radenkovic Sep 28, 2026
c28253a
TW-7426: docs: which clients may exchange for a dashboard session; 15…
radenkovic Sep 30, 2026
a137da6
TW-6922: never answer a notification the MCP proxy failed to forward
radenkovic Sep 30, 2026
3544529
TW-7426: request dashboard.session so the dashboard exchange has the …
radenkovic Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/nylas/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/nylas/cli/internal/cli/email"
"github.com/nylas/cli/internal/cli/mcp"
"github.com/nylas/cli/internal/cli/notetaker"
oauthcmd "github.com/nylas/cli/internal/cli/oauth"
"github.com/nylas/cli/internal/cli/otp"
"github.com/nylas/cli/internal/cli/rpc"
"github.com/nylas/cli/internal/cli/scheduler"
Expand Down Expand Up @@ -48,6 +49,7 @@ func main() {
rootCmd.AddCommand(calendar.NewCalendarCmd())
rootCmd.AddCommand(contacts.NewContactsCmd())
rootCmd.AddCommand(dashboard.NewDashboardCmd())
rootCmd.AddCommand(oauthcmd.NewOAuthCmd())
rootCmd.AddCommand(setup.NewSetupCmd())
rootCmd.AddCommand(scheduler.NewSchedulerCmd())
rootCmd.AddCommand(admin.NewAdminCmd())
Expand Down
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal/
mcp/ # MCP proxy server
utilities/ # Timezone, scheduling, contacts services
oauth/ # OAuth callback server
filelock/ # Cross-process file lock (OAuth refresh)
browser/ # Browser automation
tunnel/ # Cloudflare tunnel
webhookserver/ # Webhook server
Expand Down Expand Up @@ -186,6 +187,7 @@ url := qb.BuildURL(baseURL)
| `mcp/` | MCP proxy server for AI assistants |
| `config/` | Configuration validation |
| `oauth/` | OAuth callback server |
| `filelock/` | Cross-process advisory file lock (flock / LockFileEx) serialising OAuth token refresh |
| `utilities/` | Services (contacts, email, scheduling, timezone, webhook) |
| `browser/` | Browser automation |
| `tunnel/` | Cloudflare tunnel |
Expand Down
115 changes: 115 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,112 @@ nylas auth migrate # Migrate from v2 to v3

---

## OAuth (Authorization Server)

Log in to the Nylas OAuth 2.1 / OIDC authorization server. This authenticates
**you**, the person running the CLI, and is distinct from `nylas auth` (which
connects an end user's mailbox as a provider grant).

It also signs in the `nylas dashboard` commands, so `nylas dashboard login` is
not needed after it. That dashboard session is for the organization you chose
on the consent screen, lasts as long as the access token, and is renewed
automatically from the OAuth session. `nylas oauth logout` ends it too.
To change organization, run `nylas dashboard orgs switch`: it opens the
browser to sign in again, and you choose the organization there.

Without an account you can sign up on the page that opens. `--region` (or the
configured region) decides where the new organization is created; without
either it is created in the US.

```bash
nylas oauth login # Log in via the browser (authorization code + PKCE)
nylas oauth login --scope openid,email
nylas oauth login --for mcp # Scopes + resource for `nylas mcp serve --auth oauth`
nylas oauth login --region eu # Sign up with a new organization in the EU
nylas oauth status # Show the stored session and decoded token claims
nylas oauth status --verify # Also confirm the token against /oauth/userinfo
nylas oauth token # Print a valid access token, refreshing if needed
nylas oauth logout # Revoke the session and clear stored tokens
```

The CLI is a static public client (client id
`b3a94d82-fc7d-4a22-803e-e603ae0f735c`, no client secret — PKCE protects the
exchange). The browser redirects to `http://127.0.0.1:<port>/callback`, the
address the callback server binds. Tokens are stored in the system keyring;
a value too large for one keychain item (Windows allows 2560 bytes) is split
across several. The ID token is not stored: the CLI neither verifies nor uses
it.

Every CLI process on the machine shares the one stored session. Refreshing is
serialised by a lock file, `oauth-session.lock`: in `~/.config/nylas` of your
account when the session is in the system keyring (whatever `XDG_CONFIG_HOME`
says, since the keyring does not follow it either), and beside the encrypted
secrets file when the file store is used:
the server rotates the refresh token on every use and revokes the whole family
if a consumed one is replayed, so two `nylas mcp serve` processes refreshing at
once would otherwise sign you out. A process that waited on the lock uses the
tokens the other one stored instead of refreshing again. A refresh that has
started runs to completion even if the command is interrupted, because the
server has already rotated the token it was sent.

`nylas oauth status` decodes the access token and shows its audience, grants,
scopes and expiry. The claims are **decoded, not verified** — the CLI does not
check the signature; only the resource server's answer is authoritative.

Default scopes are `openid`, `email`, `offline_access` and `dashboard.session`.
`offline_access` is what makes the server issue a refresh token; without it the
session ends when the access token expires (15 minutes by default).
`dashboard.session` is what lets the CLI sign the `nylas dashboard` commands in:
the consent screen shows it as *Use the Nylas Dashboard as you, with your full
role in this organization*. The server accepts it only from the CLI's built-in
client and does not list it in its discovery document, so the CLI always sends
it rather than dropping it as not offered. A login made with `--scope` and
without `dashboard.session` works, but leaves the dashboard commands signed out.

After login, the CLI also signs the `nylas dashboard` commands in by exchanging
the access token for a dashboard session. The exchange requires the token to
carry `dashboard.session`; for a login that was not granted it, the CLI says so
and asks you to run `nylas oauth login` again. `nylas dashboard orgs switch`
adds `dashboard.session` when it signs in again. If a session from
`nylas dashboard login` is already stored for the configured server, it is kept
(its organization and app selection are unchanged); run `nylas dashboard logout`
first to use the OAuth login for the dashboard commands instead.

Use the access token with any OAuth-protected endpoint:

```bash
curl -H "Authorization: Bearer $(nylas oauth token)" https://example/resource
```

### Pointing at a local authorization server

The authorization server is hosted by `dashboard-account`, so it uses the same
base URL as the `nylas dashboard` commands:

```bash
NYLAS_DASHBOARD_ACCOUNT_URL=http://localhost:3001 nylas oauth login
```

If that server registers the CLI under a different client id, override it with
`NYLAS_OAUTH_CLIENT_ID` (it must still allow the `http://127.0.0.1/callback`
redirect URI).

The dashboard session exchange only accepts tokens from dashboard-account's
built-in first-party clients (the Nylas CLI and Nylas Mail), and only that
client may request `dashboard.session`. With any other client id the CLI leaves
`dashboard.session` out of the request (the server would refuse the whole
request otherwise), so `nylas oauth login` still succeeds, but the `nylas
dashboard` commands stay signed out; use `nylas dashboard login` for those.

The CLI resolves every endpoint from the server's
`/.well-known/oauth-authorization-server` document, and that document is built
from the server's `OAUTH_ISSUER`. If `OAUTH_ISSUER` names a host the CLI cannot
reach (for example a Cloudflare tunnel that is no longer running), login fails
even though the local port responds — set `OAUTH_ISSUER` to the address you
actually browse to.

---

## Dashboard

Manage your Nylas Dashboard account, applications, domains, and API keys directly from the CLI.
Expand All @@ -134,6 +240,14 @@ nylas dashboard status # Show current auth status
nylas dashboard refresh # Refresh session tokens
```

A dashboard session is tied to the servers it was issued for: the account URL
and both gateway URLs (`NYLAS_DASHBOARD_ACCOUNT_URL`,
`NYLAS_DASHBOARD_GATEWAY_URL`, `NYLAS_DASHBOARD_GATEWAY_US_URL`,
`NYLAS_DASHBOARD_GATEWAY_EU_URL`, or the config file). If any of them changes,
the stored session is refused rather than sent to a server that did not issue
it; log in again, or restore the settings. `nylas dashboard logout` then clears
it locally without contacting the new server.

### SSO (Direct)

```bash
Expand Down Expand Up @@ -584,6 +698,7 @@ nylas mcp install --all # Install for all detected assistants
nylas mcp status # Check installation status
nylas mcp uninstall --assistant cursor # Remove configuration
nylas mcp serve # Start MCP server (used by assistants)
nylas mcp serve --auth oauth # ...authenticating with `nylas oauth login --for mcp`
```

**Supported assistants:**
Expand Down
29 changes: 29 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,35 @@ make test-integration

**CRITICAL:** Integration tests create real resources. Always use `make ci-full` for automatic cleanup.

### OAuth authorization server tests

`internal/cli/integration/oauth_test.go` drives a real dashboard-account
authorization server instead of the Nylas API, so it needs its own variable and
skips without it:

```bash
NYLAS_OAUTH_AS_URL=http://localhost:3001 \
go test -tags integration -run TestOAuthAS ./internal/cli/integration/
```

Requirements on the server side:

- dashboard-account running (in a Tilt stack it is on port 3001)
- `/dev` routes enabled — `ENABLE_DEV_ROUTES=true` or `IS_E2E=true`. The tests
seed their own user, consent grant and authorization code through them, which
is what lets the token exchange run without a browser.
- the CLI's static public client registered, with the redirect URI
`http://127.0.0.1/callback`. Set `NYLAS_OAUTH_CLIENT_ID` if the local server
registers it under another id.

The tests front the server with a small proxy that rewrites the issuer origin in
the discovery document. dashboard-account builds every advertised endpoint from
`OAUTH_ISSUER`, and in a local stack that is frequently a tunnel hostname that is
stale or unreachable; the client under test is spec-correct and follows whatever
the document says. If you would rather fix it at the source, set
`OAUTH_ISSUER=http://localhost:3001` in `infra/.env.local` and restart the
service — the proxy then rewrites nothing.

---

## Project Structure
Expand Down
56 changes: 55 additions & 1 deletion docs/commands/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,19 @@ nylas mcp install --assistant claude-code # Specific assistant
nylas mcp install --assistant cursor # Cursor IDE
nylas mcp install --all # All detected assistants
nylas mcp install --binary /path/to/nylas # Custom binary path
nylas mcp install --assistant claude-code --auth oauth # Proxy authenticates with OAuth
```

Every assistant is configured to launch `nylas mcp serve` over STDIO, and no
credential is written into any assistant config. `--auth oauth` adds
`--auth oauth` to the launcher (run `nylas oauth login --for mcp` first).

Pointing an assistant directly at the hosted server
(`https://mcp.{us,eu}.nylas.com`) and letting it run OAuth itself is not
configured by `install` yet: which supported assistants handle remote MCP with
OAuth, and in which config format, has not been verified. The local proxy is the
compatibility path for all of them.

### Status

Check installation status:
Expand All @@ -67,9 +78,50 @@ nylas mcp uninstall --all
Start the MCP server (called by AI assistants, not directly):

```bash
nylas mcp serve
nylas mcp serve # authenticate with the API key (default)
nylas mcp serve --auth oauth # authenticate with an OAuth session
```

#### OAuth (`--auth oauth`)

Log in once for the MCP server, then point the assistant at
`nylas mcp serve --auth oauth`:

```bash
nylas oauth login --for mcp
```

`--for mcp` requests the data scopes the MCP tools use (`email.read`,
`email.send`, `calendar.read`, `calendar.write`, `contacts.read`,
`notetaker.read`, `grants.read`) plus `offline_access` and `dashboard.session`
(which signs the `nylas dashboard` commands in too; the MCP server ignores it),
and sends the MCP server
of your configured region as the RFC 8707 `resource`, so the token is issued
for that server only. Scopes the authorization server does not offer are left
out and listed.

With `--auth oauth` the proxy:

- asks for a valid token before **every** request and refreshes it as it nears
expiry (access tokens last 15 minutes). Refreshing is serialised across every
`nylas mcp serve` on the machine, so several assistants can share one login.
- sends requests to the MCP server named in the token's audience (`aud`), not
the configured region, and refuses a token whose audience names neither.
- offers the default grant (`X-Nylas-Grant-Id` and the injected `grant_id`)
only when the token's `grants` claim lists it, and does not answer
`get_grant` from the local grant store.
- on `401` with a `WWW-Authenticate` challenge, refreshes once and retries; if
that fails it tells you to run `nylas oauth login --for mcp`.
- on `403 insufficient_scope`, names the missing scope and the login command.

#### Protocol

The hosted server is stateless. The proxy sends no `Mcp-Session-Id` and ignores
one if offered. It sends `Mcp-Method` on every request, `Mcp-Name` for
`tools/call` and `prompts/get` (the server refuses a name that disagrees with
the body), and, once `initialize` has answered, `Mcp-Protocol-Version` with the
version the server negotiated.

---

## Supported Assistants
Expand Down Expand Up @@ -158,6 +210,8 @@ region: eu # or "us" (default)
```

The MCP proxy reads this setting and routes requests to the appropriate regional endpoint.
With `--auth oauth` the region is used once, at `nylas oauth login --for mcp`,
to choose the token's resource; requests then follow the token's audience.

---

Expand Down
28 changes: 28 additions & 0 deletions docs/security/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ Non-sensitive settings stored in `~/.config/nylas/config.yaml`:
- Callback port
- Local default grant mirror

### OAuth Login Callback

`nylas oauth login` and `nylas auth login` receive the redirect on a loopback
callback server (`127.0.0.1`, plus `::1` for `localhost`):

- The expected `state` is set before the browser opens, and is compared in
constant time.
- Only a request carrying that state can end the login, with a code or an
`error`. Any other request to the port is refused with 400 and the login
keeps waiting, so a stray or hostile request cannot abort it.
- The `error` value is shown only if it matches `^[a-z_]{1,64}$`, so control
characters from the URL never reach the terminal.

### Session Locks

Session writes are serialised across CLI processes by advisory file locks,
always taken in this order:

| Lock | Guards |
|------|--------|
| `dashboard-session.lock` | Dashboard session keys (renewal from OAuth, clear, reset) |
| `oauth-session.lock` | OAuth session keys (login, refresh, logout, reset) |
| `.secrets.lock` | Each read and write of the encrypted file store |

With the encrypted file store the first two sit in the config directory; with
the system keyring they sit in `~/.config/nylas/` under the account's home
directory, whatever `XDG_CONFIG_HOME` is.

---

## Testing
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
github.com/zalando/go-keyring v0.2.6
golang.org/x/crypto v0.46.0
golang.org/x/mod v0.30.0
golang.org/x/sys v0.43.0
golang.org/x/term v0.38.0
golang.org/x/text v0.32.0
golang.org/x/time v0.14.0
Expand Down Expand Up @@ -60,6 +61,5 @@ require (
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.43.0 // indirect
lukechampine.com/adiantum v1.1.1 // indirect
)
13 changes: 13 additions & 0 deletions internal/adapters/dashboard/account_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,19 @@ func (c *AccountClient) SSOStart(ctx context.Context, loginType, mode string, pr
return &result, nil
}

// ExchangeOAuthToken trades an OAuth access token for a DPoP-bound dashboard
// session. The token goes in the body: this server reads Authorization as a
// dashboard session token.
func (c *AccountClient) ExchangeOAuthToken(ctx context.Context, accessToken string) (*domain.DashboardOAuthExchangeResponse, error) {
body := map[string]any{"accessToken": accessToken}

var result domain.DashboardOAuthExchangeResponse
if err := c.doPost(ctx, "/auth/cli/oauth/exchange", body, nil, "", &result); err != nil {
return nil, fmt.Errorf("failed to exchange the OAuth session for a dashboard session: %w", err)
}
return &result, nil
}

// SSOPoll polls the SSO device flow for completion.
func (c *AccountClient) SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) {
body := map[string]any{
Expand Down
35 changes: 35 additions & 0 deletions internal/adapters/dashboard/account_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,41 @@ func TestAccountClientSSOPollVariants(t *testing.T) {
})
}

func TestAccountClientExchangeOAuthTokenSendsTheTokenInTheBody(t *testing.T) {
t.Parallel()

server := newAccountClientTestServer(t, func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, body map[string]any) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/auth/cli/oauth/exchange", r.URL.Path)
assert.Equal(t, "eyJ.access.token", body["accessToken"])
assert.Empty(t, r.Header.Get("Authorization"), "the server reads Authorization as a dashboard token")
assert.Equal(t, "test-proof", r.Header.Get("DPoP"))

writeDashboardEnvelope(t, w, map[string]any{
"userToken": "user-token",
"orgToken": "org-token",
"user": map[string]any{"publicId": "usr_1"},
"organizations": []any{},
"orgPublicId": "org_1",
"expiresAt": "2026-09-24T12:15:00.000Z",
})
})
defer server.Close()

client := &AccountClient{
baseURL: server.URL,
httpClient: server.Client(),
dpop: &mockDPoP{proof: "test-proof"},
}

resp, err := client.ExchangeOAuthToken(context.Background(), "eyJ.access.token")
require.NoError(t, err)
assert.Equal(t, "user-token", resp.UserToken)
assert.Equal(t, "usr_1", resp.User.PublicID)
assert.Equal(t, "org_1", resp.OrgPublicID)
assert.Equal(t, 2026, resp.ExpiresAt.Year())
}

func TestAccountClientRefreshPropagatesUnderlyingError(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading