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
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
47 changes: 47 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,53 @@ 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) and from
`nylas dashboard login` (which opens a dashboard management session).

```bash
nylas oauth login # Log in via the browser (authorization code + PKCE)
nylas oauth login --scope openid,email
nylas oauth status # Show the stored session
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 registers itself as a public client via RFC 7591 dynamic registration
the first time it runs, and stores the tokens in the system keyring.

Default scopes are `openid`, `email` and `offline_access`. `offline_access` is
what makes the server issue a refresh token; without it the session ends when
the access token expires (one hour).

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
```

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 Down
26 changes: 26 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,32 @@ 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 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
2 changes: 1 addition & 1 deletion internal/adapters/oauth/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func (s *CallbackServer) handleCallback(w http.ResponseWriter, r *http.Request)
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex; justify-content: center; align-items: center; height: 100vh;
margin: 0; background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); }
margin: 0; background: #f3f4f6; }
.container { text-align: center; background: white; padding: 3rem; border-radius: 1rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
h1 { color: #22c55e; margin-bottom: 1rem; }
Expand Down
156 changes: 156 additions & 0 deletions internal/adapters/oauthas/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Package oauthas implements a client for the Nylas OAuth 2.1 authorization
// server hosted by dashboard-account.
//
// These endpoints speak plain RFC 6749/7009/7591: no house {"data":...}
// envelope and no DPoP proof. That is why this does not reuse the
// dashboard.AccountClient transport, which adds both.
package oauthas

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"

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

const (
maxResponseBody = 1 << 20 // 1 MB
discoveryPath = "/.well-known/oauth-authorization-server"
defaultHTTPTimout = 30 * time.Second
)

// Client is an HTTP client for the authorization server.
type Client struct {
baseURL string
httpClient *http.Client
now func() time.Time

mu sync.Mutex
metadata *domain.OAuthServerMetadata
}

// NewClient creates a client rooted at the authorization server's base URL.
func NewClient(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{Timeout: defaultHTTPTimout},
now: time.Now,
}
}

// Metadata fetches and caches the RFC 8414 metadata document.
func (c *Client) Metadata(ctx context.Context) (*domain.OAuthServerMetadata, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.metadata != nil {
return c.metadata, nil
}

var metadata domain.OAuthServerMetadata
if err := c.getJSON(ctx, c.baseURL+discoveryPath, "", &metadata); err != nil {
return nil, fmt.Errorf("failed to discover authorization server at %s: %w", c.baseURL, err)
}
if err := metadata.Validate(); err != nil {
return nil, err
}

c.metadata = &metadata
return c.metadata, nil
}

func (c *Client) getJSON(ctx context.Context, endpoint, accessToken string, result any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
if accessToken != "" {
req.Header.Set("Authorization", "Bearer "+accessToken)
}
return c.do(req, result)
}

func (c *Client) postForm(ctx context.Context, endpoint string, form url.Values, result any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return c.do(req, result)
}

func (c *Client) postJSON(ctx context.Context, endpoint string, body, result any) error {
payload, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload)))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
return c.do(req, result)
}

func (c *Client) do(req *http.Request, result any) error {
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", version.UserAgent())

resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", domain.ErrNetworkError, err)
}
defer func() { _ = resp.Body.Close() }()

body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return parseOAuthError(resp.StatusCode, body)
}

if result == nil {
return nil
}
if err := json.Unmarshal(body, result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
return nil
}

// parseOAuthError decodes an RFC 6749 section 5.2 error body. A response that
// is not in that shape (an HTML error page, or the house envelope, whose
// "error" is an object) falls back to the status code and a body snippet.
func parseOAuthError(statusCode int, body []byte) error {
var payload struct {
Error string `json:"error"`
Description string `json:"error_description"`
}
if err := json.Unmarshal(body, &payload); err == nil && payload.Error != "" {
return &domain.OAuthError{
Code: payload.Error,
Description: payload.Description,
StatusCode: statusCode,
}
}

snippet := strings.TrimSpace(string(body))
if len(snippet) > 200 {
snippet = snippet[:200]
}
return &domain.OAuthError{
Code: "http_" + strconv.Itoa(statusCode),
Description: snippet,
StatusCode: statusCode,
}
}
Loading
Loading