diff --git a/cmd/nylas/main.go b/cmd/nylas/main.go index de16fd9..3e5b4dd 100644 --- a/cmd/nylas/main.go +++ b/cmd/nylas/main.go @@ -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" @@ -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()) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 4874536..3b8d7e7 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -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. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 63a0c88..6f77dd1 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 diff --git a/internal/adapters/oauth/server.go b/internal/adapters/oauth/server.go index 23a6887..b1033b5 100644 --- a/internal/adapters/oauth/server.go +++ b/internal/adapters/oauth/server.go @@ -184,7 +184,7 @@ func (s *CallbackServer) handleCallback(w http.ResponseWriter, r *http.Request)