Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
47 changes: 43 additions & 4 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,51 @@ nylas oauth login --device # Remote/headless sign-in or signup on another
nylas oauth login --no-browser # Same device flow without a local browser
nylas oauth login --scope openid,email
nylas oauth login --for mcp # Scopes + resource for `nylas mcp serve --auth oauth`
nylas oauth login --mcp-only # MCP-only credential, optionally limited to an IAM principal
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 # Show the stored sessions 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
nylas oauth token --credential <id> # ...of one MCP-only credential
nylas oauth logout # Revoke the default session and clear its tokens
nylas oauth logout --credential <id> # Revoke one MCP-only credential
nylas oauth logout --all # Revoke the default session and every MCP-only credential
```

### MCP-only credentials

`nylas oauth login --mcp-only` signs in for the hosted MCP server only. It
requests the `--for mcp` scopes **without** `dashboard.session`, which is what
lets the consent screen offer an IAM principal. Pick one to limit the
credential to that principal's permissions and connected accounts, or pick
none to keep your own access within the scopes you consent to. Choosing a
principal happens on the consent page; the CLI has no principal flag. It works
with the browser and with `--device`.

An MCP-only credential never signs the `nylas dashboard` commands in, and is
never exchanged for a dashboard session: a token narrowed by a principal must
not become a full-role one. Dashboard access needs a separate
`nylas oauth login`. A plain login whose consent picked a principal anyway is
refused and revoked rather than stored as the default session.

Each MCP-only credential is stored beside the default session, never in place
of it, under a short id derived from the server, the MCP server (audience),
the organization, the user and the principal. Logging in again with all of
those the same replaces that credential and revokes the one it replaced; any
other combination is stored alongside it, so two principals or two regions
never overwrite each other. `nylas oauth status` lists every credential with
its id, access mode, audience, organization and principal (never a token).

`nylas mcp serve --auth oauth` uses the default session when there is one. With
no default session it uses the only MCP-only credential, and with several it
asks for `--credential <id>`. `nylas mcp install --auth oauth --credential <id>`
pins one in the assistant's config; the id is not a secret.

When the server answers a refresh with `503 temporarily_unavailable` (it does
when it cannot evaluate a principal), the CLI retries after `Retry-After`, with
backoff, using the same refresh token, and keeps the stored sign-in if the
server stays unavailable.

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
Expand Down Expand Up @@ -177,8 +215,8 @@ 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
`nylas oauth status` decodes the access token and shows its audience,
organization, principal, 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`.
Expand Down Expand Up @@ -727,6 +765,7 @@ 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`
nylas mcp serve --auth oauth --credential <id> # ...with one `nylas oauth login --mcp-only` credential
```

**Supported assistants:**
Expand Down
22 changes: 21 additions & 1 deletion internal/adapters/oauthas/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package oauthas
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -118,7 +119,12 @@ func (c *Client) do(req *http.Request, result any) error {
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return parseOAuthError(resp.StatusCode, body)
err := parseOAuthError(resp.StatusCode, body)
var oauthErr *domain.OAuthError
if errors.As(err, &oauthErr) {
oauthErr.RetryAfter = retryAfter(resp.Header.Get("Retry-After"))
}
return err
}

if result == nil {
Expand Down Expand Up @@ -155,3 +161,17 @@ func parseOAuthError(statusCode int, body []byte) error {
StatusCode: statusCode,
}
}

// maxRetryAfter caps the delay a server can ask for, so a hostile or broken
// Retry-After cannot hold the session lock for long.
const maxRetryAfter = 30 * time.Second

// retryAfter reads a Retry-After header in its delay-seconds form. The
// HTTP-date form is not used by the authorization server and reads as zero.
func retryAfter(header string) time.Duration {
seconds, err := strconv.Atoi(strings.TrimSpace(header))
if err != nil || seconds <= 0 {
return 0
}
return min(time.Duration(seconds)*time.Second, maxRetryAfter)
}
21 changes: 21 additions & 0 deletions internal/adapters/oauthas/client_hardening_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,24 @@ func TestClient_ServerErrorTextCannotRewriteTheTerminal(t *testing.T) {
}
assert.Contains(t, oauthErr.Description, "forged")
}

func TestClient_TemporarilyUnavailableCarriesABoundedRetryAfter(t *testing.T) {
for header, want := range map[string]time.Duration{"4": 4 * time.Second, "3600": maxRetryAfter, "": 0, "soon": 0, "-1": 0} {
t.Run(header, func(t *testing.T) {
server := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) {
if header != "" {
w.Header().Set("Retry-After", header)
}
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"temporarily_unavailable"}`))
})

_, err := NewClient(server.URL).ExchangeCode(context.Background(), domain.OAuthCodeExchange{ClientID: "c"})

var oauthErr *domain.OAuthError
require.ErrorAs(t, err, &oauthErr)
assert.True(t, oauthErr.IsTemporarilyUnavailable())
assert.Equal(t, want, oauthErr.RetryAfter)
})
}
}
16 changes: 11 additions & 5 deletions internal/app/dashboard/oauth_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,17 @@ func (r *SessionRenewer) exchange(ctx context.Context, resetAppSelection bool) (
return nil, err
}
// The server refuses a token without dashboard.session with a bare
// INVALID_TOKEN. Reading the unverified claim here only turns that into
// an actionable message; the server still decides.
if claims, err := domain.DecodeOAuthAccessToken(accessToken); err == nil &&
!slices.Contains(claims.Scopes(), domain.OAuthScopeDashboardSession) {
return nil, fmt.Errorf("%w: run `nylas oauth login` again and allow dashboard access", domain.ErrDashboardSessionNotConsented)
// INVALID_TOKEN, and any token narrowed by an IAM principal. Reading the
// unverified claims here turns that into an actionable message, and
// means a principal token is never sent to the exchange at all; the
// server still decides.
if claims, err := domain.DecodeOAuthAccessToken(accessToken); err == nil {
if claims.Principal != nil {
return nil, fmt.Errorf("%w: a credential narrowed by an IAM principal is MCP-only; run `nylas oauth login` for dashboard access", domain.ErrDashboardSessionNotConsented)
}
if !slices.Contains(claims.Scopes(), domain.OAuthScopeDashboardSession) {
return nil, fmt.Errorf("%w: run `nylas oauth login` again and allow dashboard access", domain.ErrDashboardSessionNotConsented)
}
}
resp, err := r.account.ExchangeOAuthToken(ctx, accessToken)
if err != nil {
Expand Down
17 changes: 17 additions & 0 deletions internal/app/dashboard/oauth_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,20 @@ func TestSessionRenewer_ClearIfOAuthEndsOnlyAnOAuthSession(t *testing.T) {
assert.NotContains(t, oauth.data, ports.KeyDashboardUserToken)
assert.NotContains(t, oauth.data, ports.KeyDashboardSessionOrigin)
}

// A principal-narrowed token is MCP-only (TW-7622): even one that also lists
// dashboard.session must never become a full-role dashboard session.
func TestSessionRenewer_RefusesAPrincipalTokenWithoutAskingTheServer(t *testing.T) {
var exchangedWith string
secrets := newMemSecretStore()
enc := base64.RawURLEncoding.EncodeToString
payload := `{"sub":"usr_1","org":"org_1","aud":"https://mcp.us.nylas.com","exp":4102444800,` +
`"scope":"openid dashboard.session","principal":{"id":"V1StGXR8_Z5jdHi6B-myT","region":"us"}}`
token := enc([]byte(`{"alg":"EdDSA","typ":"at+jwt"}`)) + "." + enc([]byte(payload)) + ".sig"

_, err := NewSessionRenewer(exchangingAccount("new-user", &exchangedWith), secrets, &fakeOAuthTokens{token: token}, newTestLock(t)).Login(context.Background())
require.ErrorIs(t, err, domain.ErrDashboardSessionNotConsented)
assert.Contains(t, err.Error(), "MCP-only")
assert.Empty(t, exchangedWith, "the server was not asked")
assert.Empty(t, secrets.data[ports.KeyDashboardUserToken])
}
Loading