Skip to content
Open
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ Go 1.24+ · Hexagonal architecture (CLI → Port → Adapter) · Cobra CLI · **
- AI: shared helpers in `adapters/ai/base_client.go`

### Framework Gotchas
- Go regexp counted repetitions have a maximum of 1,000; validate larger protocol length bounds explicitly and use a separate charset regex (verified by device authorization tests).
- Go integration tests: `acquireRateLimit(t)` before API calls in parallel tests — omitting causes flaky 429s

### Environment Fixes
Expand Down
28 changes: 28 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ either it is created in the US.

```bash
nylas oauth login # Log in via the browser (authorization code + PKCE)
nylas oauth login --device # Remote/headless sign-in or signup on another device
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 --region eu # Sign up with a new organization in the EU
Expand All @@ -149,6 +151,20 @@ 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.

Over SSH, or with `--device` / `--no-browser`, sign-in uses the server's
advertised device authorization endpoint instead of a local callback. Open
the printed verification URL on another device and enter the displayed user
code. Approve only a sign-in you started yourself. Signup uses the same page;
if the code expires during email verification, start `nylas oauth login
--device` again. Denial, expiry and cancellation stop polling without an
automatic restart. The CLI follows the advertised polling interval, adds
five seconds after every `slow_down`, and backs off on network failures.
The region hint becomes a regional resource in device mode, fixing the token
audience and the signup region. The device code and tokens are never printed
by this flow. A server that
has device sign-in disabled refuses this mode; local browser sign-in remains
the default outside SSH, including macOS and Windows without `DISPLAY`.

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`
Expand Down Expand Up @@ -203,6 +219,18 @@ 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).

A non-production server also names its own MCP servers, and answers the
production ones with `invalid_target`. Point the regional resource at them with
`NYLAS_MCP_US_URL` / `NYLAS_MCP_EU_URL` (an `https://` origin; `http://` only
for loopback). Each replaces that region's server rather than adding one, so
an OAuth token is still only sent to the one server its audience names:

```bash
NYLAS_DASHBOARD_ACCOUNT_URL=https://dashboard-account-stg.eu.nylas.com \
NYLAS_MCP_US_URL=https://mcp-staging.us.nylas.com \
nylas oauth login --device
```

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
Expand Down
67 changes: 67 additions & 0 deletions internal/adapters/oauthas/device.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package oauthas

import (
"context"
"errors"
"net/url"
"strings"

"github.com/nylas/cli/internal/domain"
)

// StartDeviceAuthorization requests a first-party device authorization.
func (c *Client) StartDeviceAuthorization(ctx context.Context, clientID string, scopes []string, resource string) (*domain.OAuthDeviceAuthorization, error) {
metadata, err := c.Metadata(ctx)
if err != nil {
return nil, err
}
if metadata.DeviceAuthorizationEndpoint == "" {
return nil, domain.ErrOAuthMetadata
}
form := url.Values{"client_id": {clientID}, "scope": {strings.Join(scopes, " ")}}
if resource != "" {
form.Set("resource", resource)
}
var result domain.OAuthDeviceAuthorization
if err := c.postForm(ctx, metadata.DeviceAuthorizationEndpoint, form, &result); err != nil {
return nil, safeDeviceError(err)
}
if err := result.ValidateFor(metadata.Issuer); err != nil {
return nil, err
}
return &result, nil
}

// PollDeviceTokens performs one poll; the application service owns the cadence.
func (c *Client) PollDeviceTokens(ctx context.Context, clientID, deviceCode, resource string) (*domain.OAuthTokens, error) {
metadata, err := c.Metadata(ctx)
if err != nil {
return nil, err
}
form := url.Values{"grant_type": {domain.OAuthDeviceGrant}, "client_id": {clientID}, "device_code": {deviceCode}}
if resource != "" {
form.Set("resource", resource)
}
tokens, err := c.requestTokens(ctx, metadata.TokenEndpoint, form)
if err != nil {
return nil, safeDeviceError(err)
}
return tokens, nil
}

// Device errors never echo a response body that could contain bearer codes.
func safeDeviceError(err error) error {
if errors.Is(err, domain.ErrNetworkError) {
return domain.ErrNetworkError
}
var oauthErr *domain.OAuthError
if errors.As(err, &oauthErr) {
code := "server_error"
switch oauthErr.Code {
case "authorization_pending", "slow_down", "access_denied", "expired_token", "invalid_grant", "invalid_client", "unauthorized_client", "unsupported_grant_type", "invalid_scope", "invalid_target", "invalid_request", "server_error", "temporarily_unavailable":
code = oauthErr.Code
}
return &domain.OAuthError{Code: code, StatusCode: oauthErr.StatusCode}
}
return errors.New("device sign-in failed")
}
88 changes: 88 additions & 0 deletions internal/adapters/oauthas/device_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//go:build !integration

package oauthas

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/nylas/cli/internal/domain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestDeviceClient_DiscoveryAndForms(t *testing.T) {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
mux.HandleFunc(discoveryPath, func(w http.ResponseWriter, _ *http.Request) {
doc := discoveryDocument(server.URL)
doc["device_authorization_endpoint"] = server.URL + "/device-start"
_ = json.NewEncoder(w).Encode(doc)
})
mux.HandleFunc("/device-start", func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
assert.Equal(t, domain.DefaultOAuthClientID, r.Form.Get("client_id"))
assert.Equal(t, "openid dashboard.session", r.Form.Get("scope"))
assert.Equal(t, "https://mcp.us.nylas.com", r.Form.Get("resource"))
_ = json.NewEncoder(w).Encode(domain.OAuthDeviceAuthorization{DeviceCode: strings.Repeat("a", 32), UserCode: "BCDF-GHJK", VerificationURI: server.URL + "/verify", ExpiresIn: 600, Interval: 5})
})
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
assert.Equal(t, domain.OAuthDeviceGrant, r.Form.Get("grant_type"))
assert.Equal(t, strings.Repeat("a", 32), r.Form.Get("device_code"))
assert.Equal(t, "https://mcp.us.nylas.com", r.Form.Get("resource"))
_ = json.NewEncoder(w).Encode(domain.OAuthTokens{AccessToken: "token", ExpiresIn: 900})
})
c := NewClient(server.URL)
auth, err := c.StartDeviceAuthorization(context.Background(), domain.DefaultOAuthClientID, []string{"openid", "dashboard.session"}, "https://mcp.us.nylas.com")
require.NoError(t, err)
tokens, err := c.PollDeviceTokens(context.Background(), domain.DefaultOAuthClientID, auth.DeviceCode, "https://mcp.us.nylas.com")
require.NoError(t, err)
assert.Equal(t, "token", tokens.AccessToken)
}

func TestDeviceClient_ErrorsNeverEchoResponseSecrets(t *testing.T) {
server := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"authorization_pending","error_description":"secret-device-code"}`))
})
_, err := NewClient(server.URL).PollDeviceTokens(context.Background(), domain.DefaultOAuthClientID, strings.Repeat("a", 32), "")
require.Error(t, err)
assert.NotContains(t, err.Error(), "secret-device-code")
var oauthErr *domain.OAuthError
require.ErrorAs(t, err, &oauthErr)
assert.Equal(t, "authorization_pending", oauthErr.Code)
}

func TestMetadata_RejectsCrossHostDeviceEndpoint(t *testing.T) {
metadata := domain.OAuthServerMetadata{Issuer: "https://auth.example.test", AuthorizationEndpoint: "https://auth.example.test/authorize", TokenEndpoint: "https://auth.example.test/token", DeviceAuthorizationEndpoint: "https://other.example.test/device"}
require.ErrorIs(t, metadata.ValidateFor(metadata.Issuer), domain.ErrOAuthMetadata)
}

// invalid_target is how a server refuses the MCP resource. It must survive
// as itself rather than collapse into server_error, still without the body.
func TestDeviceClient_KeepsInvalidTarget(t *testing.T) {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
mux.HandleFunc(discoveryPath, func(w http.ResponseWriter, _ *http.Request) {
doc := discoveryDocument(server.URL)
doc["device_authorization_endpoint"] = server.URL + "/device-start"
_ = json.NewEncoder(w).Encode(doc)
})
mux.HandleFunc("/device-start", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_target","error_description":"server-text"}`))
})
_, err := NewClient(server.URL).StartDeviceAuthorization(context.Background(), domain.DefaultOAuthClientID, []string{"openid"}, domain.MCPResourceUS)
var oauthErr *domain.OAuthError
require.ErrorAs(t, err, &oauthErr)
assert.Equal(t, "invalid_target", oauthErr.Code)
assert.Equal(t, http.StatusBadRequest, oauthErr.StatusCode)
assert.NotContains(t, err.Error(), "server-text")
}
153 changes: 153 additions & 0 deletions internal/app/oauthlogin/device.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package oauthlogin

import (
"context"
"errors"
"fmt"
"time"

"github.com/nylas/cli/internal/domain"
"github.com/nylas/cli/internal/ports"
)

func waitDevicePoll(ctx context.Context, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}

func (s *Service) loginDevice(ctx context.Context, metadata *domain.OAuthServerMetadata, opts LoginOptions, scopes, dropped []string) (*LoginResult, error) {
// Device requests carry a regional resource rather than a browser-only
// signup hint, so an explicitly configured signup region stays fixed.
if opts.Resource == "" && opts.Region != "" {
resource, err := domain.MCPResourceForRegion(opts.Region)
if err != nil {
return nil, err
}
opts.Resource = resource
}
if s.clientID != domain.DefaultOAuthClientID {
return nil, errors.New("device sign-in requires the built-in CLI client")
}
client, ok := s.client.(ports.OAuthDeviceAuthServerClient)
if !ok || metadata.DeviceAuthorizationEndpoint == "" {
return nil, errors.New("this server does not offer device sign-in")
}
if opts.OnDeviceAuthorization == nil {
return nil, errors.New("device sign-in output is not configured")
}
// Start the deadline before the request: response latency never extends
// the server's advertised device-code lifetime.
started := s.now()
authorization, err := client.StartDeviceAuthorization(ctx, s.clientID, scopes, opts.Resource)
if err != nil {
return nil, explainDeviceTarget(err, opts.Resource)
}
if authorization == nil {
return nil, domain.ErrOAuthMetadata
}
if err := authorization.ValidateFor(metadata.Issuer); err != nil {
return nil, err
}
deadline := started.Add(time.Duration(authorization.ExpiresIn) * time.Second)
pollCtx, cancel := context.WithTimeout(ctx, deadline.Sub(s.now()))
defer cancel()
if err := opts.OnDeviceAuthorization(domain.OAuthDevicePrompt{
UserCode: authorization.UserCode, VerificationURI: authorization.VerificationURI,
VerificationURIComplete: authorization.VerificationURIComplete,
}); err != nil {
return nil, err
}
interval := time.Duration(authorization.Interval) * time.Second
if interval == 0 {
interval = 5 * time.Second
}
wait := s.waitDevice
if wait == nil {
wait = waitDevicePoll
}
for {
if err := wait(pollCtx, interval); err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, errors.New("device sign-in expired; run `nylas oauth login --device` again")
}
if !s.now().Before(deadline) {
return nil, errors.New("device sign-in expired; run `nylas oauth login --device` again")
}
tokens, err := client.PollDeviceTokens(pollCtx, s.clientID, authorization.DeviceCode, opts.Resource)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if pollCtx.Err() != nil {
return nil, errors.New("device sign-in expired; run `nylas oauth login --device` again")
}
var oauthErr *domain.OAuthError
if errors.As(err, &oauthErr) {
switch oauthErr.Code {
case "authorization_pending":
continue
case "temporarily_unavailable", "server_error":
if oauthErr.StatusCode == 503 {
interval = max(interval, min(interval*2, 60*time.Second))
continue
}
case "slow_down":
interval += 5 * time.Second
continue
case "expired_token":
return nil, errors.New("device sign-in expired; run `nylas oauth login --device` again")
case "access_denied":
return nil, errors.New("device sign-in was denied")
}
}
if errors.Is(err, domain.ErrNetworkError) {
interval = max(interval, min(interval*2, 60*time.Second))
continue
}
return nil, errors.New("device sign-in failed; start a new sign-in when ready")
}
if tokens == nil || tokens.AccessToken == "" {
return nil, errors.New("device sign-in returned no access token")
}
storeCtx, cancelStore := context.WithTimeout(context.WithoutCancel(ctx), refreshTimeout)
err = s.withSessionLock(storeCtx, func() error {
s.revokeReplacedSession(storeCtx)
if err := s.clearSession(); err != nil {
return err
}
if err := s.saveTokens(metadata.Issuer, opts.Resource, tokens); err != nil {
return errors.Join(err, s.clearSession())
}
return nil
})
cancelStore()
if err != nil {
return nil, fmt.Errorf("could not store device sign-in: %w", err)
}
return &LoginResult{Issuer: metadata.Issuer, ClientID: s.clientID, Scope: tokens.Scope,
ExpiresAt: tokens.ExpiresAt, HasRefresh: tokens.RefreshToken != "", Resource: opts.Resource,
DroppedScopes: dropped}, nil
}
}

// explainDeviceTarget says why the server refused the resource. Device errors
// never carry the server's description, so without this the reader sees a
// bare invalid_target and no way forward. The usual cause is a non-production
// server that names its own MCP servers.
func explainDeviceTarget(err error, resource string) error {
var oauthErr *domain.OAuthError
if resource == "" || !errors.As(err, &oauthErr) || oauthErr.Code != "invalid_target" {
return err
}
return fmt.Errorf("%w: this server does not accept %s as the MCP server; "+
"for a non-production server set %s or %s to the MCP server it names",
err, resource, domain.EnvMCPURLUS, domain.EnvMCPURLEU)
}
Loading
Loading