From e8019f531fea056a599f42aa1d3f0c4d2725cd7d Mon Sep 17 00:00:00 2001 From: Dan Radenkovic Date: Thu, 15 Oct 2026 13:25:56 +0200 Subject: [PATCH] [TW-7622] feat(cli): MCP-only principal-scoped login, credential isolation and OAuth status `nylas oauth login --mcp-only` requests the MCP scopes without dashboard.session, so the consent screen can offer an IAM principal. The result is stored as its own credential beside the default session, keyed by server, audience, org, user and principal: the same key replaces (and revokes) that credential, anything else is stored alongside it. A restricted token is never exchanged for a dashboard session, and the exchange refuses any token carrying a principal claim before calling the server. - oauth status lists the default session and every MCP-only credential with access mode, audience, org and principal; no token is printed - oauth token/logout --credential, logout --all; config reset clears all - mcp serve/install --auth oauth --credential ; without it serve uses the default session, else the only MCP-only credential - refresh retries 503 temporarily_unavailable after Retry-After with backoff and keeps the stored sign-in (contract section 7) Co-Authored-By: Claude Opus 5.5 --- docs/COMMANDS.md | 47 ++- internal/adapters/oauthas/client.go | 22 +- .../adapters/oauthas/client_hardening_test.go | 21 + internal/app/dashboard/oauth_session.go | 16 +- internal/app/dashboard/oauth_session_test.go | 17 + internal/app/oauthlogin/credentials.go | 288 +++++++++++++ internal/app/oauthlogin/credentials_test.go | 393 ++++++++++++++++++ internal/app/oauthlogin/device.go | 15 +- internal/app/oauthlogin/service.go | 136 ++++-- internal/app/oauthlogin/store.go | 64 +-- .../cli/integration/oauth_mcp_only_test.go | 78 ++++ internal/cli/mcp/credential_test.go | 65 +++ internal/cli/mcp/install.go | 39 +- internal/cli/mcp/serve.go | 57 ++- internal/cli/oauth/helpers.go | 31 +- internal/cli/oauth/login.go | 78 +++- internal/cli/oauth/logout.go | 40 +- internal/cli/oauth/mcp_only_test.go | 213 ++++++++++ internal/cli/oauth/oauth_test.go | 12 + internal/cli/oauth/status.go | 154 +++++-- internal/cli/oauth/token.go | 12 +- internal/domain/mcp_auth.go | 10 + internal/domain/oauth.go | 9 + internal/domain/oauth_client.go | 41 +- internal/ports/secrets.go | 4 + 25 files changed, 1699 insertions(+), 163 deletions(-) create mode 100644 internal/app/oauthlogin/credentials.go create mode 100644 internal/app/oauthlogin/credentials_test.go create mode 100644 internal/cli/integration/oauth_mcp_only_test.go create mode 100644 internal/cli/mcp/credential_test.go create mode 100644 internal/cli/oauth/mcp_only_test.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index a1409ae..b299bf7 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -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 # ...of one MCP-only credential +nylas oauth logout # Revoke the default session and clear its tokens +nylas oauth logout --credential # 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 `. `nylas mcp install --auth oauth --credential ` +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:/callback`, the @@ -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`. @@ -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 # ...with one `nylas oauth login --mcp-only` credential ``` **Supported assistants:** diff --git a/internal/adapters/oauthas/client.go b/internal/adapters/oauthas/client.go index 888b826..4e76474 100644 --- a/internal/adapters/oauthas/client.go +++ b/internal/adapters/oauthas/client.go @@ -9,6 +9,7 @@ package oauthas import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -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 { @@ -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) +} diff --git a/internal/adapters/oauthas/client_hardening_test.go b/internal/adapters/oauthas/client_hardening_test.go index 00178e7..aace146 100644 --- a/internal/adapters/oauthas/client_hardening_test.go +++ b/internal/adapters/oauthas/client_hardening_test.go @@ -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) + }) + } +} diff --git a/internal/app/dashboard/oauth_session.go b/internal/app/dashboard/oauth_session.go index a788215..52a516a 100644 --- a/internal/app/dashboard/oauth_session.go +++ b/internal/app/dashboard/oauth_session.go @@ -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 { diff --git a/internal/app/dashboard/oauth_session_test.go b/internal/app/dashboard/oauth_session_test.go index e0d8ba7..f832ac5 100644 --- a/internal/app/dashboard/oauth_session_test.go +++ b/internal/app/dashboard/oauth_session_test.go @@ -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]) +} diff --git a/internal/app/oauthlogin/credentials.go b/internal/app/oauthlogin/credentials.go new file mode 100644 index 0000000..e6ad59b --- /dev/null +++ b/internal/app/oauthlogin/credentials.go @@ -0,0 +1,288 @@ +package oauthlogin + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// An MCP-only credential (`nylas oauth login --mcp-only`) is stored beside +// the default session, never in place of it. The default session is the +// dashboard-capable login every other command uses; an MCP-only credential +// may be narrowed by an IAM principal, and a narrowed token must never be the +// one the dashboard commands exchange for a full-role session. +// +// Each credential is filed under an id derived from what makes it a +// different authorization: the server, the audience, the organization, the +// user and the principal. Logging in again with all five the same replaces +// that credential (and revokes the one it replaces); anything else adds a +// new one, so one login can never overwrite or silently broaden another. + +// ErrMCPCredentialNotFound is returned for a --credential id nothing is stored under. +var ErrMCPCredentialNotFound = errors.New("no stored MCP credential has that id") + +// ErrMCPCredentialAmbiguous is returned when several MCP-only credentials are +// stored, no default session is, and no --credential picks one. +var ErrMCPCredentialAmbiguous = errors.New("several MCP credentials are stored") + +// ErrPrincipalNeedsMCPOnly is returned when a dashboard-capable login comes +// back narrowed by a principal. Such a token is not stored as the default +// session, where the dashboard commands would try to exchange it. +var ErrPrincipalNeedsMCPOnly = errors.New("this sign-in chose an IAM principal, and a principal credential is MCP-only") + +// MCPCredentialRecord describes one stored MCP-only credential. It carries no +// token: it is what `nylas oauth status` lists and what selection matches. +type MCPCredentialRecord struct { + ID string `json:"id"` + ServerURL string `json:"server_url"` + Audience string `json:"audience"` + Org string `json:"org"` + Subject string `json:"sub"` + Principal *domain.OAuthTokenPrincipal `json:"principal,omitempty"` +} + +var credentialIDPattern = regexp.MustCompile(`^[0-9a-f]{12}$`) + +// credentialID is a short, stable name for one authorization. It is part of +// keyring key names, so it is hex and nothing else. +func credentialID(serverURL, audience, org, subject string, principal *domain.OAuthTokenPrincipal) string { + parts := []string{domain.NormalizeOAuthIssuer(serverURL), audience, org, subject, "", ""} + if principal != nil { + parts[4], parts[5] = principal.ID, principal.Region + } + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + return hex.EncodeToString(sum[:])[:12] +} + +// key is base for this service's credential: base itself for the default +// session, "oauth_mcp__" for an MCP-only credential. +func (s *Service) key(base string) string { + if s.credentialID == "" { + return base + } + return "oauth_mcp_" + s.credentialID + "_" + strings.TrimPrefix(base, "oauth_") +} + +// forCredential returns a service bound to one MCP-only credential's keys. +// It shares the client, the store and the session lock. +func (s *Service) forCredential(id string) *Service { + bound := *s + bound.credentialID = id + return &bound +} + +// CredentialID is the MCP-only credential this service is bound to, empty +// for the default session. +func (s *Service) CredentialID() string { + return s.credentialID +} + +// ListMCPCredentials returns the stored MCP-only credentials. +func (s *Service) ListMCPCredentials() ([]MCPCredentialRecord, error) { + return s.readIndex() +} + +// ForMCPCredential returns a service bound to the MCP-only credential id. +func (s *Service) ForMCPCredential(id string) (*Service, error) { + records, err := s.readIndex() + if err != nil { + return nil, err + } + for _, record := range records { + if record.ID == id { + return s.forCredential(id), nil + } + } + return nil, fmt.Errorf("%w: %q (run `nylas oauth status` to list them)", ErrMCPCredentialNotFound, id) +} + +// SelectMCPCredential picks the credential `nylas mcp serve --auth oauth` +// uses. An id picks that MCP-only credential. Without one, the default +// session is used when there is one, as it always has been; otherwise the +// only MCP-only credential, and with several the caller has to choose. +func (s *Service) SelectMCPCredential(id string) (*Service, error) { + if id != "" { + return s.ForMCPCredential(id) + } + if _, err := s.loadSession(); !errors.Is(err, domain.ErrOAuthNotLoggedIn) { + return s, err + } + records, err := s.readIndex() + if err != nil { + return nil, err + } + switch len(records) { + case 0: + return nil, domain.ErrOAuthNotLoggedIn + case 1: + return s.forCredential(records[0].ID), nil + default: + ids := make([]string, 0, len(records)) + for _, record := range records { + ids = append(ids, record.ID) + } + return nil, fmt.Errorf("%w (%s): pick one with --credential", ErrMCPCredentialAmbiguous, strings.Join(ids, ", ")) + } +} + +// mcpOnlyRecord checks what an MCP-only login returned and names the +// credential it becomes. The claims are decoded, not verified; they decide +// where the token is filed and sent, and the MCP server checks the rest. +func mcpOnlyRecord(serverURL, resource string, tokens *domain.OAuthTokens) (*MCPCredentialRecord, error) { + claims, err := domain.DecodeOAuthAccessToken(tokens.AccessToken) + if err != nil { + return nil, fmt.Errorf("an MCP-only sign-in must return a JWT access token: %w", err) + } + if slices.Contains(claims.Scopes(), domain.OAuthScopeDashboardSession) { + return nil, errors.New("the server granted dashboard.session to an MCP-only sign-in") + } + audience, err := domain.MCPEndpointFromAudience(claims.Audience) + if err != nil { + return nil, err + } + if audience != resource { + return nil, fmt.Errorf("%w: the token is for %s, not %s", domain.ErrMCPResource, audience, resource) + } + if claims.Org == "" || claims.Subject == "" { + return nil, errors.New("the MCP access token names no organization or user") + } + if claims.Principal != nil { + if err := claims.Principal.Validate(); err != nil { + return nil, err + } + // The server binds a principal to its own region's MCP server. + if regional, err := domain.MCPResourceForRegion(claims.Principal.Region); err != nil || regional != audience { + return nil, fmt.Errorf("%w: a %s principal on a token for %s", domain.ErrOAuthPrincipalClaim, claims.Principal.Region, audience) + } + } + return &MCPCredentialRecord{ + ID: credentialID(serverURL, audience, claims.Org, claims.Subject, claims.Principal), + ServerURL: serverURL, + Audience: audience, + Org: claims.Org, + Subject: claims.Subject, + Principal: claims.Principal, + }, nil +} + +// storeLogin keeps a login's tokens: in its own credential for an MCP-only +// login, and as the default session otherwise. A token it refuses to keep is +// revoked, so a refused login leaves no live token family on the server. +func (s *Service) storeLogin(ctx context.Context, issuer string, opts LoginOptions, tokens *domain.OAuthTokens) (*MCPCredentialRecord, error) { + if opts.MCPOnly { + record, err := mcpOnlyRecord(s.client.ServerURL(), opts.Resource, tokens) + if err != nil { + s.revokeTokens(ctx, tokens) + return nil, err + } + return record, s.withSessionLock(ctx, func() error { + return s.storeMCPCredential(ctx, issuer, opts.Resource, record, tokens) + }) + } + + if claims, err := domain.DecodeOAuthAccessToken(tokens.AccessToken); err == nil && claims.Principal != nil { + s.revokeTokens(ctx, tokens) + return nil, fmt.Errorf("%w: run `nylas oauth login --mcp-only` to keep it", ErrPrincipalNeedsMCPOnly) + } + return nil, s.withSessionLock(ctx, func() error { + s.revokeReplacedSession(ctx) + // Clear whatever session is stored first: a login against a different + // server must not leave that server's old tokens filed under the new + // server's name, where checkIssuer would then accept them. + if err := s.clearSession(); err != nil { + return err + } + if err := s.saveTokens(issuer, opts.Resource, tokens); err != nil { + return errors.Join(err, s.clearSession()) + } + return nil + }) +} + +// storeMCPCredential replaces the credential record names and indexes it. +// The caller holds the session lock. +func (s *Service) storeMCPCredential(ctx context.Context, issuer, resource string, record *MCPCredentialRecord, tokens *domain.OAuthTokens) error { + bound := s.forCredential(record.ID) + bound.revokeReplacedSession(ctx) + if err := bound.clearSession(); err != nil { + return err + } + if err := bound.saveTokens(issuer, resource, tokens); err != nil { + return errors.Join(err, bound.clearSession()) + } + records, err := s.readIndex() + if err != nil { + return errors.Join(err, bound.clearSession()) + } + records = slices.DeleteFunc(records, func(r MCPCredentialRecord) bool { return r.ID == record.ID }) + if err := s.writeIndex(append(records, *record)); err != nil { + return errors.Join(err, bound.clearSession()) + } + return nil +} + +// revokeTokens revokes a token set the CLI is not keeping. Best effort. +func (s *Service) revokeTokens(ctx context.Context, tokens *domain.OAuthTokens) { + token := tokens.RefreshToken + if token == "" { + token = tokens.AccessToken + } + _ = s.client.Revoke(ctx, s.clientID, token) +} + +// LogoutMCPCredential revokes and removes one MCP-only credential. The +// default session and the dashboard session are left alone. +func (s *Service) LogoutMCPCredential(ctx context.Context, id string) error { + bound, err := s.ForMCPCredential(id) + if err != nil { + return err + } + return s.withSessionLock(ctx, func() error { + logoutErr := bound.logoutLocked(ctx) + records, err := s.readIndex() + if err != nil { + return errors.Join(logoutErr, err) + } + records = slices.DeleteFunc(records, func(r MCPCredentialRecord) bool { return r.ID == id }) + return errors.Join(logoutErr, s.writeIndex(records)) + }) +} + +// readIndex reads the MCP credential index. An entry that is not in the +// shape this package writes is dropped rather than turned into key names. +func (s *Service) readIndex() ([]MCPCredentialRecord, error) { + raw, err := s.getSecret(ports.KeyOAuthMCPCredentials) + if err != nil || raw == "" { + return nil, err + } + var records []MCPCredentialRecord + if err := json.Unmarshal([]byte(raw), &records); err != nil { + return nil, fmt.Errorf("failed to read the MCP credential index: %w", err) + } + return slices.DeleteFunc(records, func(r MCPCredentialRecord) bool { + return !credentialIDPattern.MatchString(r.ID) + }), nil +} + +func (s *Service) writeIndex(records []MCPCredentialRecord) error { + if len(records) == 0 { + return s.deleteSecret(ports.KeyOAuthMCPCredentials) + } + raw, err := json.Marshal(records) + if err != nil { + return err + } + if err := s.secrets.Set(ports.KeyOAuthMCPCredentials, string(raw)); err != nil { + return fmt.Errorf("failed to store the MCP credential index: %w", err) + } + return nil +} diff --git a/internal/app/oauthlogin/credentials_test.go b/internal/app/oauthlogin/credentials_test.go new file mode 100644 index 0000000..870df46 --- /dev/null +++ b/internal/app/oauthlogin/credentials_test.go @@ -0,0 +1,393 @@ +//go:build !integration + +package oauthlogin + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + principalA = "V1StGXR8_Z5jdHi6B-myT" + principalB = "Uakgb_J5m9g-0JDMbcJqL" +) + +// tokenClaims are the claims an access token in these tests carries. +type tokenClaims struct { + aud string + scope string + org string + principal *domain.OAuthTokenPrincipal +} + +func principalToken(t *testing.T, c tokenClaims) string { + t.Helper() + claims := map[string]any{ + "sub": "user-1", + "org": c.org, + "aud": c.aud, + "scope": c.scope, + "client_id": domain.DefaultOAuthClientID, + "exp": time.Now().Add(15 * time.Minute).Unix(), + } + if c.principal != nil { + claims["principal"] = c.principal + } + body, err := json.Marshal(claims) + require.NoError(t, err) + return "eyJhbGciOiJub25lIn0." + base64.RawURLEncoding.EncodeToString(body) + ".sig" +} + +func usPrincipal(id string) *domain.OAuthTokenPrincipal { + return &domain.OAuthTokenPrincipal{ID: id, Region: "us"} +} + +const mcpOnlyScope = "email.read offline_access" + +// issue makes the fake server answer the next code exchange with a token +// carrying c, and a refresh token named refresh. +func (f *fixture) issue(t *testing.T, c tokenClaims, refresh string) { + t.Helper() + token := principalToken(t, c) + f.client.ExchangeCodeFunc = func(context.Context, domain.OAuthCodeExchange) (*domain.OAuthTokens, error) { + return &domain.OAuthTokens{AccessToken: token, RefreshToken: refresh, Scope: c.scope, ExpiresAt: f.clock.Add(15 * time.Minute)}, nil + } +} + +func mcpOnlyLogin() LoginOptions { + return LoginOptions{Scopes: domain.MCPOnlyOAuthScopes(), Resource: domain.MCPResourceUS, MCPOnly: true} +} + +func TestMCPOnlyOAuthScopes_NeverRequestDashboardSession(t *testing.T) { + assert.NotContains(t, domain.MCPOnlyOAuthScopes(), domain.OAuthScopeDashboardSession) + assert.Contains(t, domain.MCPOAuthScopes(), domain.OAuthScopeDashboardSession, "the dashboard-capable preset is unchanged") + assert.Contains(t, domain.MCPOnlyOAuthScopes(), domain.OAuthScopeOfflineAccess) +} + +func TestLogin_MCPOnlyIsStoredBesideTheDefaultSession(t *testing.T) { + f := newFixture(t) + _, err := f.service.Login(context.Background(), LoginOptions{}) + require.NoError(t, err) + defaultToken := f.secrets.GetAll()[ports.KeyOAuthAccessToken] + + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalA)}, "refresh-a") + result, err := f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + + assert.NotContains(t, f.client.AuthorizationCalls[1].Scopes, domain.OAuthScopeDashboardSession) + require.NotNil(t, result.Credential) + assert.Equal(t, principalA, result.Credential.Principal.ID) + assert.Equal(t, domain.MCPResourceUS, result.Credential.Audience) + + stored := f.secrets.GetAll() + assert.Equal(t, defaultToken, stored[ports.KeyOAuthAccessToken], "an MCP-only login never replaces the default session") + assert.Equal(t, "mock-refresh-token", stored[ports.KeyOAuthRefreshToken]) + assert.Equal(t, "refresh-a", stored["oauth_mcp_"+result.Credential.ID+"_refresh_token"]) + assert.Empty(t, f.client.RevokeCalls, "nothing was replaced, so nothing is revoked") + assert.NotContains(t, stored[ports.KeyOAuthMCPCredentials], "refresh-a", "the index carries no token") +} + +func TestLogin_MCPOnlyRejectsARequestThatWouldGrantDashboardAccess(t *testing.T) { + f := newFixture(t) + opts := mcpOnlyLogin() + opts.Scopes = domain.MCPOAuthScopes() + _, err := f.service.Login(context.Background(), opts) + require.ErrorContains(t, err, "cannot request dashboard.session") + assert.Empty(t, f.client.AuthorizationCalls) + + opts = mcpOnlyLogin() + opts.Resource = "" + _, err = f.service.Login(context.Background(), opts) + require.Error(t, err) +} + +func TestLogin_TwoPrincipalsDoNotOverwriteEachOther(t *testing.T) { + f := newFixture(t) + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalA)}, "refresh-a") + a, err := f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalB)}, "refresh-b") + b, err := f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1"}, "refresh-none") + none, err := f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + + ids := map[string]bool{a.Credential.ID: true, b.Credential.ID: true, none.Credential.ID: true} + assert.Len(t, ids, 3, "a different principal, or none, is a different authorization") + + records, err := f.service.ListMCPCredentials() + require.NoError(t, err) + assert.Len(t, records, 3) + for id, refresh := range map[string]string{a.Credential.ID: "refresh-a", b.Credential.ID: "refresh-b", none.Credential.ID: "refresh-none"} { + bound, err := f.service.ForMCPCredential(id) + require.NoError(t, err) + session, err := bound.Status() + require.NoError(t, err) + assert.Equal(t, refresh, session.Tokens.RefreshToken) + } + assert.Empty(t, f.client.RevokeCalls) +} + +func TestLogin_SamePrincipalReplacesOnlyItsOwnCredential(t *testing.T) { + f := newFixture(t) + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalA)}, "refresh-a1") + first, err := f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalB)}, "refresh-b") + _, err = f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalA)}, "refresh-a2") + again, err := f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + + assert.Equal(t, first.Credential.ID, again.Credential.ID) + assert.Equal(t, []string{"refresh-a1"}, f.client.RevokeCalls, "the replaced family is revoked, and only that one") + records, err := f.service.ListMCPCredentials() + require.NoError(t, err) + assert.Len(t, records, 2) +} + +func TestLogin_MCPOnlyRefusesATokenItCannotFileSafely(t *testing.T) { + for name, claims := range map[string]tokenClaims{ + "dashboard.session granted": {aud: domain.MCPResourceUS, scope: "email.read dashboard.session", org: "org-1"}, + "principal in another region": {aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", + principal: &domain.OAuthTokenPrincipal{ID: principalA, Region: "eu"}}, + "malformed principal id": {aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", + principal: &domain.OAuthTokenPrincipal{ID: "../../etc", Region: "us"}}, + "another audience": {aud: domain.MCPResourceEU, scope: mcpOnlyScope, org: "org-1"}, + "no organization": {aud: domain.MCPResourceUS, scope: mcpOnlyScope}, + } { + t.Run(name, func(t *testing.T) { + f := newFixture(t) + f.issue(t, claims, "refresh-refused") + _, err := f.service.Login(context.Background(), mcpOnlyLogin()) + require.Error(t, err) + assert.Empty(t, f.secrets.GetAll(), "nothing is stored") + assert.Equal(t, []string{"refresh-refused"}, f.client.RevokeCalls, "a token the CLI will not keep is revoked") + }) + } +} + +func TestLogin_DashboardLoginNeverStoresAPrincipalToken(t *testing.T) { + f := newFixture(t) + _, err := f.service.Login(context.Background(), LoginOptions{}) + require.NoError(t, err) + before := f.secrets.GetAll() + + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalA)}, "refresh-p") + _, err = f.service.Login(context.Background(), mcpLogin()) + require.ErrorIs(t, err, ErrPrincipalNeedsMCPOnly) + assert.Equal(t, before, f.secrets.GetAll(), "the dashboard-capable session is untouched") + assert.Equal(t, []string{"refresh-p"}, f.client.RevokeCalls) +} + +// devicePrincipalClient answers a device poll with a principal token. +type devicePrincipalClient struct { + *deviceTestClient + tokens *domain.OAuthTokens +} + +func (c *devicePrincipalClient) PollDeviceTokens(context.Context, string, string, string) (*domain.OAuthTokens, error) { + c.polls++ + return c.tokens, nil +} + +func TestDeviceLogin_MCPOnlyIsStoredAsItsOwnCredential(t *testing.T) { + f, c, opts, _ := deviceFixture(t) + scope := mcpOnlyScope + client := &devicePrincipalClient{deviceTestClient: c, tokens: &domain.OAuthTokens{ + AccessToken: principalToken(t, tokenClaims{aud: domain.MCPResourceEU, scope: scope, org: "org-1", principal: &domain.OAuthTokenPrincipal{ID: principalA, Region: "eu"}}), + RefreshToken: "device-refresh", Scope: scope, ExpiresAt: f.clock.Add(15 * time.Minute), + }} + f.service.client = client + opts.Scopes, opts.Resource, opts.MCPOnly = domain.MCPOnlyOAuthScopes(), domain.MCPResourceEU, true + + result, err := f.service.Login(context.Background(), *opts) + require.NoError(t, err) + + assert.NotContains(t, c.scopes, domain.OAuthScopeDashboardSession) + assert.Equal(t, domain.MCPResourceEU, c.resource) + require.NotNil(t, result.Credential) + assert.Equal(t, "eu", result.Credential.Principal.Region) + assert.NotContains(t, f.secrets.GetAll(), ports.KeyOAuthAccessToken, "the default session is not written") +} + +func TestSelectMCPCredential(t *testing.T) { + f := newFixture(t) + _, err := f.service.SelectMCPCredential("") + require.ErrorIs(t, err, domain.ErrOAuthNotLoggedIn) + + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalA)}, "refresh-a") + a, err := f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + + selected, err := f.service.SelectMCPCredential("") + require.NoError(t, err) + assert.Equal(t, a.Credential.ID, selected.CredentialID(), "the only MCP-only credential is used when there is no default session") + + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalB)}, "refresh-b") + _, err = f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + _, err = f.service.SelectMCPCredential("") + require.ErrorIs(t, err, ErrMCPCredentialAmbiguous) + + selected, err = f.service.SelectMCPCredential(a.Credential.ID) + require.NoError(t, err) + assert.Equal(t, a.Credential.ID, selected.CredentialID()) + _, err = f.service.SelectMCPCredential("000000000000") + require.ErrorIs(t, err, ErrMCPCredentialNotFound) + + f.client.ExchangeCodeFunc = nil + _, err = f.service.Login(context.Background(), LoginOptions{}) + require.NoError(t, err) + selected, err = f.service.SelectMCPCredential("") + require.NoError(t, err) + assert.Empty(t, selected.CredentialID(), "a default session keeps being the default") +} + +func TestAccessToken_RefreshesOnlyTheSelectedCredential(t *testing.T) { + f := newFixture(t) + _, err := f.service.Login(context.Background(), LoginOptions{}) + require.NoError(t, err) + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalA)}, "refresh-a") + a, err := f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + + f.advance(time.Hour) + bound, err := f.service.ForMCPCredential(a.Credential.ID) + require.NoError(t, err) + _, err = bound.AccessToken(context.Background()) + require.NoError(t, err) + + assert.Equal(t, []string{"refresh-a"}, f.client.RefreshCalls) + assert.Equal(t, []string{domain.MCPResourceUS}, f.client.RefreshResources) + stored := f.secrets.GetAll() + assert.Equal(t, "mock-refresh-token-2", stored["oauth_mcp_"+a.Credential.ID+"_refresh_token"]) + assert.Equal(t, "mock-refresh-token", stored[ports.KeyOAuthRefreshToken], "the default session is not refreshed") +} + +func TestRefresh_RetriesTemporarilyUnavailableWithTheSameRefreshToken(t *testing.T) { + f := newFixture(t) + _, err := f.service.Login(context.Background(), LoginOptions{}) + require.NoError(t, err) + waits := []time.Duration{} + f.service.waitRetry = func(_ context.Context, d time.Duration) error { waits = append(waits, d); return nil } + answers := []error{ + &domain.OAuthError{Code: "temporarily_unavailable", StatusCode: 503, RetryAfter: 3 * time.Second}, + &domain.OAuthError{Code: "temporarily_unavailable", StatusCode: 503}, + } + f.client.RefreshFunc = func(context.Context, string, string, string) (*domain.OAuthTokens, error) { + if len(answers) > 0 { + err := answers[0] + answers = answers[1:] + return nil, err + } + return &domain.OAuthTokens{AccessToken: "fresh", RefreshToken: "rotated", ExpiresAt: f.clock.Add(time.Hour)}, nil + } + + f.advance(2 * time.Hour) + token, err := f.service.AccessToken(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "fresh", token) + assert.Equal(t, []string{"mock-refresh-token", "mock-refresh-token", "mock-refresh-token"}, f.client.RefreshCalls) + assert.Equal(t, []time.Duration{3 * time.Second, 2 * time.Second}, waits, "Retry-After first, then backoff") + assert.Equal(t, "rotated", f.secrets.GetAll()[ports.KeyOAuthRefreshToken]) +} + +func TestRefresh_KeepsTheSessionWhenTheServerStaysUnavailable(t *testing.T) { + f := newFixture(t) + _, err := f.service.Login(context.Background(), LoginOptions{}) + require.NoError(t, err) + f.service.waitRetry = func(context.Context, time.Duration) error { return nil } + f.client.RefreshFunc = func(context.Context, string, string, string) (*domain.OAuthTokens, error) { + return nil, &domain.OAuthError{Code: "temporarily_unavailable", StatusCode: 503} + } + + f.advance(2 * time.Hour) + _, err = f.service.AccessToken(context.Background()) + require.ErrorContains(t, err, "your sign-in is kept") + assert.Len(t, f.client.RefreshCalls, refreshAttempts) + assert.Equal(t, "mock-refresh-token", f.secrets.GetAll()[ports.KeyOAuthRefreshToken], "a 503 never signs the user out") +} + +func TestRefresh_DoesNotRetryAnotherError(t *testing.T) { + f := newFixture(t) + _, err := f.service.Login(context.Background(), LoginOptions{}) + require.NoError(t, err) + f.service.waitRetry = func(context.Context, time.Duration) error { + t.Fatal("an invalid_grant is final") + return nil + } + f.client.RefreshFunc = func(context.Context, string, string, string) (*domain.OAuthTokens, error) { + return nil, &domain.OAuthError{Code: "invalid_grant", StatusCode: 400} + } + f.advance(2 * time.Hour) + _, err = f.service.AccessToken(context.Background()) + require.Error(t, err) + assert.Len(t, f.client.RefreshCalls, 1) +} + +func TestLogoutMCPCredential_RemovesOnlyThatCredential(t *testing.T) { + f := newFixture(t) + _, err := f.service.Login(context.Background(), LoginOptions{}) + require.NoError(t, err) + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalA)}, "refresh-a") + a, err := f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalB)}, "refresh-b") + b, err := f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + + require.NoError(t, f.service.LogoutMCPCredential(context.Background(), a.Credential.ID)) + + assert.Equal(t, []string{"refresh-a"}, f.client.RevokeCalls) + for key := range f.secrets.GetAll() { + assert.False(t, strings.Contains(key, a.Credential.ID), "key %s outlived the logout", key) + } + records, err := f.service.ListMCPCredentials() + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, b.Credential.ID, records[0].ID) + assert.Equal(t, "mock-refresh-token", f.secrets.GetAll()[ports.KeyOAuthRefreshToken], "the default session stays") + require.ErrorIs(t, f.service.LogoutMCPCredential(context.Background(), a.Credential.ID), ErrMCPCredentialNotFound) +} + +func TestClearSessionLocked_ClearsEveryCredential(t *testing.T) { + f := newFixture(t) + _, err := f.service.Login(context.Background(), LoginOptions{}) + require.NoError(t, err) + f.issue(t, tokenClaims{aud: domain.MCPResourceUS, scope: mcpOnlyScope, org: "org-1", principal: usPrincipal(principalA)}, "refresh-a") + _, err = f.service.Login(context.Background(), mcpOnlyLogin()) + require.NoError(t, err) + + require.NoError(t, ClearSessionLocked(context.Background(), f.secrets, f.lock)) + assert.Empty(t, f.secrets.GetAll()) +} + +func TestReadIndex_DropsAnEntryThatIsNotAnID(t *testing.T) { + f := newFixture(t) + require.NoError(t, f.secrets.Set(ports.KeyOAuthMCPCredentials, `[{"id":"../api_key"},{"id":"0123456789ab"}]`)) + records, err := f.service.ListMCPCredentials() + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, "0123456789ab", records[0].ID) + + require.NoError(t, f.secrets.Set(ports.KeyOAuthMCPCredentials, `not json`)) + _, err = f.service.ListMCPCredentials() + require.True(t, err != nil && !errors.Is(err, domain.ErrOAuthNotLoggedIn)) +} diff --git a/internal/app/oauthlogin/device.go b/internal/app/oauthlogin/device.go index 1f53fee..87ec5f1 100644 --- a/internal/app/oauthlogin/device.go +++ b/internal/app/oauthlogin/device.go @@ -118,23 +118,12 @@ func (s *Service) loginDevice(ctx context.Context, metadata *domain.OAuthServerM 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 - }) + credential, err := s.storeLogin(storeCtx, metadata.Issuer, opts, tokens) 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 + return loginResult(metadata.Issuer, s.clientID, opts, tokens, dropped, credential), nil } } diff --git a/internal/app/oauthlogin/service.go b/internal/app/oauthlogin/service.go index d5fb7c0..d710af0 100644 --- a/internal/app/oauthlogin/service.go +++ b/internal/app/oauthlogin/service.go @@ -28,6 +28,11 @@ type Service struct { lock ports.CrossProcessLock now func() time.Time waitDevice func(context.Context, time.Duration) error + // waitRetry sleeps before a refresh retry; swapped in tests. + waitRetry func(context.Context, time.Duration) error + // credentialID binds the service to one MCP-only credential's keys. + // Empty is the default session. See credentials.go. + credentialID string } // NewService creates a login service for the given public client id — @@ -89,6 +94,9 @@ type LoginResult struct { // DroppedScopes are requested scopes the server does not offer, left out // of the request because LoginOptions.DropUnsupportedScopes was set. DroppedScopes []string + // Credential is set for an MCP-only login: where it was stored, and the + // principal it is narrowed by, if any. Nil for the default session. + Credential *MCPCredentialRecord } // LoginOptions shape an authorization request. @@ -113,6 +121,12 @@ type LoginOptions struct { // does not list, instead of letting the whole request fail with // invalid_scope. The server only offers data scopes a deployment enables. DropUnsupportedScopes bool + + // MCPOnly stores the login as its own MCP-only credential instead of the + // default session. It requires a Resource and never requests + // dashboard.session, which is what lets the consent screen offer an IAM + // principal. + MCPOnly bool } // Login runs the browser authorization code flow and stores the tokens. @@ -126,6 +140,14 @@ func (s *Service) Login(ctx context.Context, opts LoginOptions) (*LoginResult, e return nil, err } } + if opts.MCPOnly { + if opts.Resource == "" { + return nil, errors.New("an MCP-only login needs an MCP server as its resource") + } + if slices.Contains(scopes, domain.OAuthScopeDashboardSession) { + return nil, errors.New("an MCP-only login cannot request dashboard.session") + } + } metadata, err := s.client.Metadata(ctx) if err != nil { @@ -242,31 +264,24 @@ func (s *Service) Login(ctx context.Context, opts LoginOptions) (*LoginResult, e return nil, err } - if err := s.withSessionLock(exchangeCtx, func() error { - s.revokeReplacedSession(exchangeCtx) - // Clear whatever session is stored first: a login against a different - // server must not leave that server's old tokens filed under the new - // server's name, where checkIssuer would then accept them. - 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 - }); err != nil { + credential, err := s.storeLogin(exchangeCtx, metadata.Issuer, opts, tokens) + if err != nil { return nil, err } + return loginResult(metadata.Issuer, clientID, opts, tokens, dropped, credential), nil +} +func loginResult(issuer, clientID string, opts LoginOptions, tokens *domain.OAuthTokens, dropped []string, credential *MCPCredentialRecord) *LoginResult { return &LoginResult{ - Issuer: metadata.Issuer, + Issuer: issuer, ClientID: clientID, Scope: tokens.Scope, ExpiresAt: tokens.ExpiresAt, HasRefresh: tokens.RefreshToken != "", Resource: opts.Resource, DroppedScopes: dropped, - }, nil + Credential: credential, + } } // revokeReplacedSession revokes the refresh token of the session a login is @@ -389,7 +404,7 @@ func (s *Service) refreshLocked(ctx context.Context, rejected string, minValid t // one lost. The refresh runs to completion on its own deadline. refreshCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), refreshTimeout) defer cancel() - tokens, err := s.client.Refresh(refreshCtx, session.ClientID, session.Tokens.RefreshToken, session.Resource) + tokens, err := s.refresh(refreshCtx, session) if err != nil { return err } @@ -410,20 +425,54 @@ func (s *Service) refreshLocked(ctx context.Context, rejected string, minValid t return accessToken, nil } +// refreshAttempts bounds how often a refresh the server answered with 503 is +// sent again. +const refreshAttempts = 3 + +// refresh spends the refresh token. A 503 means a read the server needed +// failed before it rotated anything (it answers that way when IAM cannot +// evaluate a principal), so the same refresh token is sent again after +// Retry-After, with backoff. It is never a reason to drop the stored session. +func (s *Service) refresh(ctx context.Context, session *Session) (*domain.OAuthTokens, error) { + wait := s.waitRetry + if wait == nil { + wait = waitDevicePoll + } + delay := time.Second + for attempt := 1; ; attempt++ { + tokens, err := s.client.Refresh(ctx, session.ClientID, session.Tokens.RefreshToken, session.Resource) + var oauthErr *domain.OAuthError + if err == nil || !errors.As(err, &oauthErr) || !oauthErr.IsTemporarilyUnavailable() { + return tokens, err + } + if attempt == refreshAttempts { + return nil, fmt.Errorf("%w; your sign-in is kept, try again shortly", err) + } + pause := delay + if oauthErr.RetryAfter > 0 { + pause = oauthErr.RetryAfter + } + if waitErr := wait(ctx, pause); waitErr != nil { + return nil, fmt.Errorf("%w; your sign-in is kept, try again shortly", err) + } + delay *= 2 + } +} + // dropSpentRefreshToken handles a refresh whose answer could not be stored. // The server has already rotated spent, so if it is still what the store // holds it is deleted: the next command then asks for a login instead of // replaying it. A new refresh token that did get stored is kept, because the // write order in saveTokens makes the rest of the session recover from it. func (s *Service) dropSpentRefreshToken(spent string, saveErr error) error { - stored, err := s.getSecret(ports.KeyOAuthRefreshToken) + stored, err := s.getSecret(s.key(ports.KeyOAuthRefreshToken)) if err != nil { return errors.Join(saveErr, err) } if stored != spent { return saveErr } - if err := s.deleteSecret(ports.KeyOAuthRefreshToken); err != nil { + if err := s.deleteSecret(s.key(ports.KeyOAuthRefreshToken)); err != nil { return errors.Join(saveErr, err) } return fmt.Errorf("%w; run `nylas oauth login` again", saveErr) @@ -450,35 +499,38 @@ func (s *Service) UserInfo(ctx context.Context) (*domain.OAuthUserInfo, error) { func (s *Service) Logout(ctx context.Context) error { // Under the lock, so a refresh in another process cannot write a rotated // session back after this one has cleared it. - return s.withSessionLock(ctx, func() error { - session, err := s.loadSession() - if err != nil { - if errors.Is(err, domain.ErrOAuthNotLoggedIn) { - return s.clearSession() - } - return err - } + return s.withSessionLock(ctx, func() error { return s.logoutLocked(ctx) }) +} - // A session from another server is cleared but not revoked: sending - // its token here would hand it to a server that never issued it. - if err := s.checkIssuer(session); err != nil { - if clearErr := s.clearSession(); clearErr != nil { - return clearErr - } - return fmt.Errorf("%w; the local session was cleared but not revoked", err) +// logoutLocked is Logout for a caller that holds the session lock. +func (s *Service) logoutLocked(ctx context.Context) error { + session, err := s.loadSession() + if err != nil { + if errors.Is(err, domain.ErrOAuthNotLoggedIn) { + return s.clearSession() } + return err + } - token := session.Tokens.RefreshToken - if token == "" { - token = session.Tokens.AccessToken + // A session from another server is cleared but not revoked: sending + // its token here would hand it to a server that never issued it. + if err := s.checkIssuer(session); err != nil { + if clearErr := s.clearSession(); clearErr != nil { + return clearErr } - revokeErr := s.client.Revoke(ctx, session.ClientID, token) + return fmt.Errorf("%w; the local session was cleared but not revoked", err) + } - if err := s.clearSession(); err != nil { - return err - } - return revokeErr - }) + token := session.Tokens.RefreshToken + if token == "" { + token = session.Tokens.AccessToken + } + revokeErr := s.client.Revoke(ctx, session.ClientID, token) + + if err := s.clearSession(); err != nil { + return err + } + return revokeErr } // loadSessionForServer loads the session only if it was issued by the server diff --git a/internal/app/oauthlogin/store.go b/internal/app/oauthlogin/store.go index 7b432e6..40e7e3b 100644 --- a/internal/app/oauthlogin/store.go +++ b/internal/app/oauthlogin/store.go @@ -57,11 +57,11 @@ func (s *Service) loadSession() (*Session, error) { if err != nil { return nil, err } - accessToken, err := s.getSecret(ports.KeyOAuthAccessToken) + accessToken, err := s.getSecret(s.key(ports.KeyOAuthAccessToken)) if err != nil { return nil, err } - serverURL, err := s.getSecret(ports.KeyOAuthServerURL) + serverURL, err := s.getSecret(s.key(ports.KeyOAuthServerURL)) if err != nil { return nil, err } @@ -82,12 +82,12 @@ func (s *Service) readSession() (*Session, error) { key string target *string }{ - {ports.KeyOAuthServerURL, &session.ServerURL}, - {ports.KeyOAuthIssuer, &session.Issuer}, - {ports.KeyOAuthResource, &session.Resource}, - {ports.KeyOAuthScope, &session.Tokens.Scope}, - {ports.KeyOAuthRefreshToken, &session.Tokens.RefreshToken}, - {ports.KeyOAuthAccessToken, &session.Tokens.AccessToken}, + {s.key(ports.KeyOAuthServerURL), &session.ServerURL}, + {s.key(ports.KeyOAuthIssuer), &session.Issuer}, + {s.key(ports.KeyOAuthResource), &session.Resource}, + {s.key(ports.KeyOAuthScope), &session.Tokens.Scope}, + {s.key(ports.KeyOAuthRefreshToken), &session.Tokens.RefreshToken}, + {s.key(ports.KeyOAuthAccessToken), &session.Tokens.AccessToken}, } { value, err := s.getSecret(field.key) if err != nil { @@ -99,7 +99,7 @@ func (s *Service) readSession() (*Session, error) { return nil, domain.ErrOAuthNotLoggedIn } - expiresAt, err := s.getSecret(ports.KeyOAuthExpiresAt) + expiresAt, err := s.getSecret(s.key(ports.KeyOAuthExpiresAt)) if err != nil { return nil, err } @@ -143,15 +143,15 @@ func (s *Service) saveTokens(issuer, resource string, tokens *domain.OAuthTokens key string value string }{ - {ports.KeyOAuthIssuer, issuer}, - {ports.KeyOAuthServerURL, s.client.ServerURL()}, - {ports.KeyOAuthResource, resource}, - {ports.KeyOAuthScope, tokens.Scope}, - {legacyKeyOAuthClientID, ""}, - {ports.KeyOAuthIDToken, ""}, - {ports.KeyOAuthRefreshToken, tokens.RefreshToken}, - {ports.KeyOAuthAccessToken, tokens.AccessToken}, - {ports.KeyOAuthExpiresAt, expiresAt}, + {s.key(ports.KeyOAuthIssuer), issuer}, + {s.key(ports.KeyOAuthServerURL), s.client.ServerURL()}, + {s.key(ports.KeyOAuthResource), resource}, + {s.key(ports.KeyOAuthScope), tokens.Scope}, + {s.key(legacyKeyOAuthClientID), ""}, + {s.key(ports.KeyOAuthIDToken), ""}, + {s.key(ports.KeyOAuthRefreshToken), tokens.RefreshToken}, + {s.key(ports.KeyOAuthAccessToken), tokens.AccessToken}, + {s.key(ports.KeyOAuthExpiresAt), expiresAt}, } for _, entry := range ordered { @@ -168,20 +168,36 @@ func (s *Service) saveTokens(issuer, resource string, tokens *domain.OAuthTokens return nil } +// clearSession removes this service's credential: the default session, or +// one MCP-only credential's keys. The MCP credential index is not touched. func (s *Service) clearSession() error { - return ClearSession(s.secrets) + var errs []error + for _, key := range sessionKeys { + errs = append(errs, s.deleteSecret(s.key(key))) + } + return errors.Join(errs...) } -// ClearSessionLocked is ClearSession under the cross-process session lock, so -// a refresh in flight in another process (`nylas mcp serve`) cannot write a +// ClearSessionLocked removes every stored OAuth credential, the default +// session and each MCP-only one, under the cross-process session lock, so a +// refresh in flight in another process (`nylas mcp serve`) cannot write a // rotated session back after the clear. func ClearSessionLocked(ctx context.Context, secrets ports.SecretStore, lock ports.CrossProcessLock) error { s := &Service{secrets: secrets, lock: lock} - return s.withSessionLock(ctx, s.clearSession) + return s.withSessionLock(ctx, func() error { + records, err := s.readIndex() + errs := []error{err, s.clearSession()} + for _, record := range records { + errs = append(errs, s.forCredential(record.ID).clearSession()) + } + errs = append(errs, s.deleteSecret(ports.KeyOAuthMCPCredentials)) + return errors.Join(errs...) + }) } -// ClearSession removes every stored OAuth session key without contacting the -// server. The caller must hold the session lock; see ClearSessionLocked. +// ClearSession removes every key of the default OAuth session without +// contacting the server. The caller must hold the session lock; see +// ClearSessionLocked. func ClearSession(secrets ports.SecretStore) error { var errs []error for _, key := range sessionKeys { diff --git a/internal/cli/integration/oauth_mcp_only_test.go b/internal/cli/integration/oauth_mcp_only_test.go new file mode 100644 index 0000000..7145222 --- /dev/null +++ b/internal/cli/integration/oauth_mcp_only_test.go @@ -0,0 +1,78 @@ +//go:build integration + +package integration + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// An MCP-only device sign-in against a fake AS: it never requests +// dashboard.session, never calls the dashboard exchange, and is stored as +// its own credential that status lists and token --credential reads. +func TestCLI_OAuthMCPOnly_FakeServerLogin(t *testing.T) { + require.NotEmpty(t, testBinary, "build the CLI before this integration test") + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + var exchanges atomic.Int32 + payload, err := json.Marshal(map[string]any{ + "sub": "user-1", "org": "org-1", "aud": server.URL, "scope": "email.read offline_access", + "exp": time.Now().Add(15 * time.Minute).Unix(), "principal": map[string]string{"id": "V1StGXR8_Z5jdHi6B-myT", "region": "us"}, + }) + require.NoError(t, err) + token := "e30." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(domain.OAuthServerMetadata{Issuer: server.URL, AuthorizationEndpoint: server.URL + "/authorize", TokenEndpoint: server.URL + "/token", DeviceAuthorizationEndpoint: server.URL + "/device"}) + }) + mux.HandleFunc("/device", func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + assert.NotContains(t, strings.Fields(r.Form.Get("scope")), domain.OAuthScopeDashboardSession) + assert.Equal(t, server.URL, r.Form.Get("resource")) + _ = json.NewEncoder(w).Encode(domain.OAuthDeviceAuthorization{DeviceCode: "memory-only-device-code", UserCode: "BCDF-GHJK", VerificationURI: server.URL + "/verify", ExpiresIn: 600, Interval: 1}) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(domain.OAuthTokens{AccessToken: token, RefreshToken: "refresh-secret", TokenType: "Bearer", Scope: "email.read offline_access", ExpiresIn: 900}) + }) + mux.HandleFunc("/auth/cli/oauth/exchange", func(w http.ResponseWriter, _ *http.Request) { + exchanges.Add(1) + w.WriteHeader(http.StatusForbidden) + }) + env := map[string]string{"NYLAS_DASHBOARD_ACCOUNT_URL": server.URL, "NYLAS_MCP_US_URL": server.URL, "NYLAS_DISABLE_KEYRING": "true", + "XDG_CONFIG_HOME": t.TempDir(), "SSH_CONNECTION": "", "SSH_CLIENT": "", "SSH_TTY": "", "NYLAS_OAUTH_CLIENT_ID": ""} + + out, stderr, err := runCLIWithOverrides(30*time.Second, env, "oauth", "login", "--mcp-only", "--device", "--region", "us") + require.NoError(t, err, stderr) + assert.Contains(t, out, "MCP only") + assert.Contains(t, out, "V1StGXR8_Z5jdHi6B-myT (us)") + assert.Zero(t, exchanges.Load(), "an MCP-only login is never exchanged for a dashboard session") + assert.NotContains(t, out+stderr, token) + assert.NotContains(t, out+stderr, "refresh-secret") + + status, stderr, err := runCLIWithOverrides(10*time.Second, env, "oauth", "status") + require.NoError(t, err, stderr) + assert.Contains(t, status, "No default session") + assert.Contains(t, status, "limited to an IAM principal") + assert.NotContains(t, status, token) + id := "" + for _, line := range strings.Split(status, "\n") { + if rest, ok := strings.CutPrefix(line, "MCP credential "); ok { + id = strings.TrimSpace(rest) + } + } + require.NotEmpty(t, id) + + printed, stderr, err := runCLIWithOverrides(10*time.Second, env, "oauth", "token", "--credential", id) + require.NoError(t, err, stderr) + assert.Equal(t, token, strings.TrimSpace(printed)) +} diff --git a/internal/cli/mcp/credential_test.go b/internal/cli/mcp/credential_test.go new file mode 100644 index 0000000..c0058b5 --- /dev/null +++ b/internal/cli/mcp/credential_test.go @@ -0,0 +1,65 @@ +//go:build !integration + +package mcp + +import ( + "os" + "path/filepath" + "testing" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildProxyWith_CredentialRequiresOAuth(t *testing.T) { + _, err := buildProxyWith(authAPIKey, "0123456789ab") + require.ErrorContains(t, err, "--credential requires --auth oauth") +} + +func TestBuildProxyWith_CredentialSelectsThatCredential(t *testing.T) { + withOAuthCredentials(t, stubCredentials{err: domain.ErrOAuthNotLoggedIn}, nil) + original := newOAuthCredentialFn + var asked string + newOAuthCredentialFn = func(id string) (ports.MCPCredentialSource, error) { + asked = id + return stubCredentials{cred: &domain.MCPCredential{Token: "t", Endpoint: domain.MCPResourceUS, GrantScoped: true}}, nil + } + t.Cleanup(func() { newOAuthCredentialFn = original }) + + proxy, err := buildProxyWith(authOAuth, "0123456789ab") + require.NoError(t, err) + assert.NotNil(t, proxy) + assert.Equal(t, "0123456789ab", asked) +} + +func TestServeArgsWith(t *testing.T) { + args, err := serveArgsWith(authOAuth, "0123456789ab") + require.NoError(t, err) + assert.Equal(t, []string{"mcp", "serve", "--auth", "oauth", "--credential", "0123456789ab"}, args) + + _, err = serveArgsWith(authAPIKey, "0123456789ab") + require.Error(t, err) + + args, err = serveArgsWith(authOAuth, "") + require.NoError(t, err) + assert.Equal(t, []string{"mcp", "serve", "--auth", "oauth"}, args) +} + +func TestInstallServer_WithoutAuthKeepsThePinnedCredential(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", t.TempDir()) + configPath := filepath.Join(t.TempDir(), "config.json") + a := testAssistant("cursor", configPath) + args, err := serveArgsWith(authOAuth, "0123456789ab") + require.NoError(t, err) + require.NoError(t, installServer(a, "/usr/local/bin/nylas", args)) + + require.NoError(t, installServer(a, "/opt/bin/nylas", nil)) + + raw, err := os.ReadFile(configPath) // #nosec G304 -- test temp file + require.NoError(t, err) + assert.Contains(t, string(raw), `"--credential"`) + assert.Contains(t, string(raw), `"0123456789ab"`) +} diff --git a/internal/cli/mcp/install.go b/internal/cli/mcp/install.go index 6295120..62cc506 100644 --- a/internal/cli/mcp/install.go +++ b/internal/cli/mcp/install.go @@ -21,6 +21,7 @@ func newInstallCmd() *cobra.Command { binaryPath string installAll bool authMode string + credential string ) cmd := &cobra.Command{ @@ -41,7 +42,9 @@ Supported assistants: Every assistant is configured to launch 'nylas mcp serve' over STDIO. No credential is written to any assistant config: with --auth api-key (default) serve reads the API key from the keyring, and with --auth oauth it uses the -session from 'nylas oauth login --for mcp'. +session from 'nylas oauth login --for mcp'. --credential (with --auth oauth) +pins the MCP-only credential from 'nylas oauth login --mcp-only' that serve +uses; it is an id, not a secret. Connecting an assistant straight to the hosted server (https://mcp.us.nylas.com or https://mcp.eu.nylas.com) and letting it run @@ -69,9 +72,9 @@ of them.`, // with: re-running install must not silently turn an OAuth // install back into an API key one. var serveArgs []string - if cmd.Flags().Changed("auth") { + if cmd.Flags().Changed("auth") || credential != "" { var err error - if serveArgs, err = serveArgsFor(authMode); err != nil { + if serveArgs, err = serveArgsWith(authMode, credential); err != nil { return err } } @@ -83,10 +86,25 @@ of them.`, cmd.Flags().StringVarP(&binaryPath, "binary", "b", "", "Path to nylas binary (default: auto-detect)") cmd.Flags().BoolVar(&installAll, "all", false, "Install for all detected assistants") cmd.Flags().StringVar(&authMode, "auth", authAPIKey, "credential 'nylas mcp serve' authenticates with: api-key or oauth") + cmd.Flags().StringVar(&credential, "credential", "", + "with --auth oauth, the MCP-only credential 'nylas mcp serve' uses (an id from 'nylas oauth status')") return cmd } +// serveArgsWith is serveArgsFor with an MCP-only credential id, which only +// --auth oauth takes. +func serveArgsWith(authMode, credential string) ([]string, error) { + if credential == "" { + return serveArgsFor(authMode) + } + if authMode != authOAuth { + return nil, common.NewUserError("--credential requires --auth oauth", "") + } + args, err := serveArgsFor(authMode) + return append(args, "--credential", credential), err +} + // serveArgsFor is the argument list written into an assistant config. The // default stays byte-for-byte what earlier releases wrote. func serveArgsFor(authMode string) ([]string, error) { @@ -238,7 +256,7 @@ func installedServeArgs(config map[string]any, a Assistant) []string { args, _ := server["args"].([]any) for i := 0; i+1 < len(args); i++ { if args[i] == "--auth" && args[i+1] == authOAuth { - return oauthArgs + return append(oauthArgs, installedCredentialArgs(args)...) } } return defaultArgs @@ -384,3 +402,16 @@ func detectBinaryPath() (string, error) { return "", fmt.Errorf("could not find nylas binary") } + +// installedCredentialArgs keeps the --credential an OAuth install pinned, so +// re-running install does not silently move it to another credential. +func installedCredentialArgs(args []any) []string { + for i := 0; i+1 < len(args); i++ { + if args[i] == "--credential" { + if id, ok := args[i+1].(string); ok && id != "" { + return []string{"--credential", id} + } + } + } + return nil +} diff --git a/internal/cli/mcp/serve.go b/internal/cli/mcp/serve.go index 8c838b6..f1bd711 100644 --- a/internal/cli/mcp/serve.go +++ b/internal/cli/mcp/serve.go @@ -24,7 +24,10 @@ const ( ) func newServeCmd() *cobra.Command { - var authMode string + var ( + authMode string + credential string + ) cmd := &cobra.Command{ Use: "serve", @@ -45,12 +48,17 @@ MCP server and adds local enhancements: Authentication (--auth): api-key (default) the API key from 'nylas auth login', sent to the MCP server of the configured region. - oauth the OAuth session from 'nylas oauth login --for mcp'. The token + oauth the OAuth session from 'nylas oauth login --for mcp', or an + MCP-only credential from 'nylas oauth login --mcp-only'. The token is refreshed as it nears expiry (one refresher per machine, shared with every other 'nylas mcp serve'), and requests go to the MCP server named in the token's audience. The default grant is only offered as a hint when the token lists it; local grant lookup is off, since local grants belong to the API key's application. + --credential picks an MCP-only credential by the id 'nylas oauth + status' lists. Without it the default session is used; with no + default session, the only MCP-only credential is, and with several + --credential is required. The server communicates via STDIO (standard input/output). @@ -60,9 +68,13 @@ For more information: https://developer.nylas.com/docs/dev-guide/mcp/`, # Proxy with an OAuth session nylas oauth login --for mcp - nylas mcp serve --auth oauth`, + nylas mcp serve --auth oauth + + # Proxy with an MCP-only credential limited to an IAM principal + nylas oauth login --mcp-only + nylas mcp serve --auth oauth --credential `, RunE: func(cmd *cobra.Command, _ []string) error { - proxy, err := buildProxy(authMode) + proxy, err := buildProxyWith(authMode, credential) if err != nil { return err } @@ -71,6 +83,8 @@ For more information: https://developer.nylas.com/docs/dev-guide/mcp/`, } cmd.Flags().StringVar(&authMode, "auth", authAPIKey, "credential to authenticate with: api-key or oauth") + cmd.Flags().StringVar(&credential, "credential", "", + "with --auth oauth, the MCP-only credential to use (an id from 'nylas oauth status')") return cmd } @@ -78,11 +92,20 @@ For more information: https://developer.nylas.com/docs/dev-guide/mcp/`, // buildProxy constructs the proxy for an --auth value. Anything but the two // known modes is refused rather than defaulted. func buildProxy(authMode string) (*mcp.Proxy, error) { + return buildProxyWith(authMode, "") +} + +// buildProxyWith is buildProxy with an MCP-only credential id, which only +// --auth oauth can use. +func buildProxyWith(authMode, credential string) (*mcp.Proxy, error) { + if credential != "" && authMode != authOAuth { + return nil, common.NewUserError("--credential requires --auth oauth", "") + } switch authMode { case authAPIKey: return buildAPIKeyProxy() case authOAuth: - return buildOAuthProxy() + return buildOAuthProxy(credential) default: return nil, common.NewUserError( fmt.Sprintf("unknown --auth value %q", authMode), @@ -116,20 +139,34 @@ func buildAPIKeyProxy() (*mcp.Proxy, error) { return proxy, nil } -// newOAuthCredentialsFn is swapped in tests. -var newOAuthCredentialsFn = func() (ports.MCPCredentialSource, error) { +// newOAuthCredentialsFn and newOAuthCredentialFn are swapped in tests. The +// first serves the default selection, the second a --credential id. +var ( + newOAuthCredentialsFn = func() (ports.MCPCredentialSource, error) { return oauthCredentials("") } + newOAuthCredentialFn = oauthCredentials +) + +func oauthCredentials(id string) (ports.MCPCredentialSource, error) { svc, err := oauthcli.NewLoginService() if err != nil { return nil, err } - return oauthlogin.NewMCPCredentials(svc), nil + selected, err := svc.SelectMCPCredential(id) + if err != nil { + return nil, err + } + return oauthlogin.NewMCPCredentials(selected), nil } // oauthPreflightTimeout bounds the start-up check, which may refresh. const oauthPreflightTimeout = 30 * time.Second -func buildOAuthProxy() (*mcp.Proxy, error) { - creds, err := newOAuthCredentialsFn() +func buildOAuthProxy(credential string) (*mcp.Proxy, error) { + newCreds := newOAuthCredentialsFn + if credential != "" { + newCreds = func() (ports.MCPCredentialSource, error) { return newOAuthCredentialFn(credential) } + } + creds, err := newCreds() if err != nil { return nil, err } diff --git a/internal/cli/oauth/helpers.go b/internal/cli/oauth/helpers.go index eca9dc7..0356265 100644 --- a/internal/cli/oauth/helpers.go +++ b/internal/cli/oauth/helpers.go @@ -30,9 +30,38 @@ type loginService interface { AccessTokenValidFor(ctx context.Context, minValid time.Duration) (string, error) UserInfo(ctx context.Context) (*domain.OAuthUserInfo, error) Logout(ctx context.Context) error + ListMCPCredentials() ([]oauthlogin.MCPCredentialRecord, error) + ForMCPCredential(id string) (loginService, error) + LogoutMCPCredential(ctx context.Context, id string) error } -var createLoginServiceFn = func() (loginService, error) { return createLoginService() } +// serviceAdapter makes oauthlogin.Service a loginService: ForMCPCredential +// returns the interface so a test fake can stand in for a credential too. +type serviceAdapter struct{ *oauthlogin.Service } + +func (a serviceAdapter) ForMCPCredential(id string) (loginService, error) { + bound, err := a.Service.ForMCPCredential(id) + if err != nil { + return nil, err + } + return serviceAdapter{bound}, nil +} + +var createLoginServiceFn = func() (loginService, error) { + svc, err := createLoginService() + if err != nil { + return nil, err + } + return serviceAdapter{svc}, nil +} + +// forCredential is svc, or the MCP-only credential id names when id is set. +func forCredential(svc loginService, id string) (loginService, error) { + if id == "" { + return svc, nil + } + return svc.ForMCPCredential(id) +} var ( exchangeDashboardSessionFn = dashboard.ExchangeOAuthSession diff --git a/internal/cli/oauth/login.go b/internal/cli/oauth/login.go index 729668c..8502e69 100644 --- a/internal/cli/oauth/login.go +++ b/internal/cli/oauth/login.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/signal" "strings" @@ -27,6 +28,7 @@ func newLoginCmd() *cobra.Command { region string device bool noBrowser bool + mcpOnly bool ) cmd := &cobra.Command{ @@ -53,7 +55,19 @@ the configured region (https://mcp.us.nylas.com or https://mcp.eu.nylas.com) as the RFC 8707 resource, so the token is issued for that server only. Scopes the server does not offer are left out and reported. -Every login also requests dashboard.session, which signs the 'nylas dashboard' +--mcp-only logs in for the hosted MCP server only. It requests what --for mcp +does except dashboard.session, so the consent screen can offer an IAM +principal: pick one to limit the credential to that principal's permissions +and connected accounts, or none to keep your own access within the scopes you +consent to. The credential is stored as its own MCP credential beside your +default session, never in place of it, and it never signs the 'nylas +dashboard' commands in: dashboard access needs a separate 'nylas oauth login'. +Logging in again for the same server, organization and principal replaces that +credential; any other combination is stored alongside it. 'nylas oauth status' +lists them, and 'nylas mcp serve --auth oauth --credential ' uses one. It +works with --device too. + +Every other login also requests dashboard.session, which signs the 'nylas dashboard' commands in. The consent screen shows it as "Use the Nylas Dashboard as you, with your full role in this organization". It is never advertised by the server and never left out, except with NYLAS_OAUTH_CLIENT_ID, which the server @@ -78,6 +92,10 @@ so a token is only ever sent to the one server its region names.`, # Sign in over SSH without a local browser nylas oauth login --device + # An MCP-only credential, optionally limited to an IAM principal + nylas oauth login --mcp-only + nylas oauth login --mcp-only --device --region eu + # Sign up with a new organization in the EU nylas oauth login --region eu @@ -89,11 +107,11 @@ so a token is only ever sent to the one server its region names.`, // the login; --for mcp still validates it for the resource. if region == "" { region = configuredRegion() - if _, err := normalizeRegion(region); err != nil && preset == "" { + if _, err := normalizeRegion(region); err != nil && preset == "" && !mcpOnly { region = "" } } - opts, err := loginOptions(preset, scopes, region) + opts, err := loginOptionsFor(preset, scopes, region, mcpOnly) if err != nil { return err } @@ -159,6 +177,15 @@ so a token is only ever sent to the one server its region names.`, " No refresh token issued — request the offline_access scope to stay signed in.") } + // An MCP-only credential is never exchanged: it may be narrowed by + // a principal, and dashboard.session was not requested. + if result.Credential != nil { + printMCPCredential(out, result.Credential) + _, _ = fmt.Fprintln(out, " Dashboard: not signed in by this login; run `nylas oauth login` for dashboard access") + _, _ = fmt.Fprintf(out, " Use it: nylas mcp serve --auth oauth --credential %s\n", result.Credential.ID) + return nil + } + // The OAuth login already succeeded, so a failed exchange is reported // rather than returned: only the dashboard commands are affected. exchangeCtx, cancelExchange := afterConsentContext(ctx) @@ -188,9 +215,38 @@ so a token is only ever sent to the one server its region names.`, cmd.Flags().BoolVar(&device, "device", false, "sign in or sign up using a code on another device") cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "use device sign-in without opening a local browser") + cmd.Flags().BoolVar(&mcpOnly, "mcp-only", false, + "log in for the hosted MCP server only, as its own credential: no dashboard access, and the consent screen may limit it to an IAM principal") return cmd } +// loginOptionsFor is loginOptions with --mcp-only: the MCP preset without +// dashboard.session, stored as its own credential. +func loginOptionsFor(preset string, scopes []string, region string, mcpOnly bool) (oauthlogin.LoginOptions, error) { + if !mcpOnly { + return loginOptions(preset, scopes, region) + } + if preset != "" && preset != loginPresetMCP { + return oauthlogin.LoginOptions{}, common.NewUserError( + fmt.Sprintf("--mcp-only cannot be combined with --for %s", preset), + "--mcp-only already logs in for the MCP server", + ) + } + if len(scopes) > 0 { + return oauthlogin.LoginOptions{}, common.NewUserError( + "--mcp-only and --scope cannot be combined", + "--mcp-only chooses the scopes the MCP tools need", + ) + } + opts, err := loginOptions(loginPresetMCP, nil, region) + if err != nil { + return oauthlogin.LoginOptions{}, err + } + opts.Scopes = domain.MCPOnlyOAuthScopes() + opts.MCPOnly = true + return opts, nil +} + // loginOptions turns the flags into a login request. A preset decides both // the scopes and the resource, so it cannot be combined with --scope: a // hand-picked scope list under the MCP resource is a token the MCP tools @@ -245,3 +301,19 @@ func normalizeRegion(region string) (string, error) { func isSSHSession() bool { return os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != "" || os.Getenv("SSH_TTY") != "" } + +// printMCPCredential describes an MCP-only credential without its token. +func printMCPCredential(out io.Writer, record *oauthlogin.MCPCredentialRecord) { + _, _ = fmt.Fprintln(out, " Access: MCP only (no dashboard access)") + _, _ = fmt.Fprintf(out, " Credential: %s\n", record.ID) + _, _ = fmt.Fprintf(out, " Audience: %s\n", record.Audience) + _, _ = fmt.Fprintf(out, " Org: %s\n", record.Org) + _, _ = fmt.Fprintf(out, " Principal: %s\n", describePrincipal(record.Principal)) +} + +func describePrincipal(principal *domain.OAuthTokenPrincipal) string { + if principal == nil { + return "none (your own access, within the consented scopes)" + } + return fmt.Sprintf("%s (%s)", principal.ID, principal.Region) +} diff --git a/internal/cli/oauth/logout.go b/internal/cli/oauth/logout.go index 842c58b..369fae3 100644 --- a/internal/cli/oauth/logout.go +++ b/internal/cli/oauth/logout.go @@ -1,13 +1,20 @@ package oauth import ( + "errors" + "github.com/spf13/cobra" "github.com/nylas/cli/internal/cli/common" ) func newLogoutCmd() *cobra.Command { - return &cobra.Command{ + var ( + credential string + all bool + ) + + cmd := &cobra.Command{ Use: "logout", Short: "Revoke the OAuth session and clear stored tokens", Long: `Revoke the refresh token and remove the stored session. @@ -15,8 +22,16 @@ func newLogoutCmd() *cobra.Command { Revoking the refresh token takes the whole token family with it. Local tokens are cleared even when the server cannot be reached. A dashboard session that came from 'nylas oauth login' is ended too; one from -'nylas dashboard login' is left alone.`, +'nylas dashboard login' is left alone. + +Without flags this logs out of the default session and leaves MCP-only +credentials ('nylas oauth login --mcp-only') in place. --credential logs out +of one of those instead, and --all logs out of the default session and every +MCP-only credential.`, RunE: func(cmd *cobra.Command, _ []string) error { + if credential != "" && all { + return common.NewUserError("--credential and --all cannot be combined", "") + } svc, err := createLoginServiceFn() if err != nil { return wrapOAuthError(err) @@ -25,9 +40,24 @@ session that came from 'nylas oauth login' is ended too; one from ctx, cancel := common.CreateContext() defer cancel() + if credential != "" { + if err := svc.LogoutMCPCredential(ctx, credential); err != nil { + return wrapOAuthError(err) + } + _, _ = common.Green.Fprintf(cmd.OutOrStdout(), "✓ Logged out of MCP credential %s\n", credential) + return nil + } + dashboardErr, logoutErr := clearDashboardSessionFn(ctx, func() error { return svc.Logout(ctx) }) + if all { + records, err := svc.ListMCPCredentials() + logoutErr = errors.Join(logoutErr, err) + for _, record := range records { + logoutErr = errors.Join(logoutErr, svc.LogoutMCPCredential(ctx, record.ID)) + } + } if logoutErr != nil { return wrapOAuthError(logoutErr) } @@ -40,4 +70,10 @@ session that came from 'nylas oauth login' is ended too; one from return nil }, } + + cmd.Flags().StringVar(&credential, "credential", "", + "log out of only this MCP-only credential (an id from 'nylas oauth status')") + cmd.Flags().BoolVar(&all, "all", false, + "log out of the default session and every MCP-only credential") + return cmd } diff --git a/internal/cli/oauth/mcp_only_test.go b/internal/cli/oauth/mcp_only_test.go new file mode 100644 index 0000000..6e5c7a4 --- /dev/null +++ b/internal/cli/oauth/mcp_only_test.go @@ -0,0 +1,213 @@ +//go:build !integration + +package oauth + +import ( + "context" + "encoding/base64" + "encoding/json" + "testing" + "time" + + "github.com/nylas/cli/internal/app/oauthlogin" + "github.com/nylas/cli/internal/cli/testutil" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// credentialsFake is a fakeService holding MCP-only credentials too. +type credentialsFake struct { + *fakeService + records []oauthlogin.MCPCredentialRecord + bound map[string]*fakeService + loggedOut []string +} + +func (f *credentialsFake) ListMCPCredentials() ([]oauthlogin.MCPCredentialRecord, error) { + return f.records, nil +} + +func (f *credentialsFake) ForMCPCredential(id string) (loginService, error) { + if svc, ok := f.bound[id]; ok { + return svc, nil + } + return nil, oauthlogin.ErrMCPCredentialNotFound +} + +func (f *credentialsFake) LogoutMCPCredential(_ context.Context, id string) error { + f.loggedOut = append(f.loggedOut, id) + return nil +} + +func withCredentials(t *testing.T, svc *credentialsFake) *fakeDashboard { + t.Helper() + dash := withService(t, svc.fakeService) + createLoginServiceFn = func() (loginService, error) { return svc, nil } + return dash +} + +func principalSession(t *testing.T) *oauthlogin.Session { + t.Helper() + body, err := json.Marshal(map[string]any{ + "sub": "user-1", "org": "org-1", "aud": domain.MCPResourceUS, "scope": "email.read offline_access", + "exp": time.Now().Add(time.Hour).Unix(), "principal": map[string]string{"id": "V1StGXR8_Z5jdHi6B-myT", "region": "us"}, + }) + require.NoError(t, err) + return &oauthlogin.Session{ + Issuer: "https://auth.example.test", ClientID: "client-1", Resource: domain.MCPResourceUS, + Tokens: domain.OAuthTokens{ + AccessToken: "e30." + base64.RawURLEncoding.EncodeToString(body) + ".sig", RefreshToken: "rt-secret", + Scope: "email.read offline_access", ExpiresAt: time.Now().Add(time.Hour), + }, + } +} + +func TestLoginOptionsFor_MCPOnly(t *testing.T) { + opts, err := loginOptionsFor("", nil, "eu", true) + require.NoError(t, err) + assert.True(t, opts.MCPOnly) + assert.Equal(t, domain.MCPResourceEU, opts.Resource) + assert.NotContains(t, opts.Scopes, domain.OAuthScopeDashboardSession) + + opts, err = loginOptionsFor("mcp", nil, "", true) + require.NoError(t, err) + assert.True(t, opts.MCPOnly) + + _, err = loginOptionsFor("", []string{"openid"}, "", true) + require.Error(t, err) + _, err = loginOptionsFor("dashboard", nil, "", true) + require.Error(t, err) + + opts, err = loginOptionsFor("mcp", nil, "", false) + require.NoError(t, err) + assert.False(t, opts.MCPOnly) + assert.Contains(t, opts.Scopes, domain.OAuthScopeDashboardSession, "--for mcp still signs the dashboard in") +} + +func TestLoginCmd_MCPOnlyNeverExchangesForADashboardSession(t *testing.T) { + svc := &fakeService{loginResult: &oauthlogin.LoginResult{ + Issuer: "i", ClientID: "c", Resource: domain.MCPResourceUS, HasRefresh: true, + Credential: &oauthlogin.MCPCredentialRecord{ + ID: "0123456789ab", Audience: domain.MCPResourceUS, Org: "org-1", + Principal: &domain.OAuthTokenPrincipal{ID: "V1StGXR8_Z5jdHi6B-myT", Region: "us"}, + }, + }} + dash := withService(t, svc) + + stdout, _, err := testutil.ExecuteSubCommand(newLoginCmd(), "--mcp-only") + require.NoError(t, err) + + require.NotNil(t, svc.loginOpts) + assert.True(t, svc.loginOpts.MCPOnly) + assert.NotContains(t, svc.loginOpts.Scopes, domain.OAuthScopeDashboardSession) + assert.Nil(t, dash.exchangedWith, "a restricted credential is never exchanged") + assert.Contains(t, stdout, "MCP only") + assert.Contains(t, stdout, "0123456789ab") + assert.Contains(t, stdout, "V1StGXR8_Z5jdHi6B-myT (us)") + assert.Contains(t, stdout, "nylas oauth login` for dashboard access") + assert.Contains(t, stdout, "--credential 0123456789ab") +} + +func TestLoginCmd_MCPOnlyWorksWithDevice(t *testing.T) { + svc := &fakeService{loginResult: &oauthlogin.LoginResult{Issuer: "i", ClientID: "c", HasRefresh: true, + Credential: &oauthlogin.MCPCredentialRecord{ID: "0123456789ab"}}} + withService(t, svc) + + _, _, err := testutil.ExecuteSubCommand(newLoginCmd(), "--mcp-only", "--device") + require.NoError(t, err) + assert.True(t, svc.loginOpts.MCPOnly) + assert.True(t, svc.loginOpts.Device) +} + +func TestStatusCmd_ListsMCPOnlyCredentialsWithoutTokens(t *testing.T) { + session := principalSession(t) + svc := &credentialsFake{ + fakeService: &fakeService{statusErr: domain.ErrOAuthNotLoggedIn}, + records: []oauthlogin.MCPCredentialRecord{{ID: "0123456789ab"}}, + bound: map[string]*fakeService{"0123456789ab": {session: session}}, + } + withCredentials(t, svc) + + stdout, _, err := testutil.ExecuteSubCommand(newStatusCmd()) + require.NoError(t, err) + + assert.Contains(t, stdout, "No default session") + assert.Contains(t, stdout, "MCP credential 0123456789ab") + assert.Contains(t, stdout, "MCP only, limited to an IAM principal") + assert.Contains(t, stdout, "Principal: V1StGXR8_Z5jdHi6B-myT (us)") + assert.Contains(t, stdout, "Audience: "+domain.MCPResourceUS) + assert.NotContains(t, stdout, session.Tokens.AccessToken) + assert.NotContains(t, stdout, "rt-secret") +} + +func TestStatusCmd_NothingStoredIsNotLoggedIn(t *testing.T) { + svc := &credentialsFake{fakeService: &fakeService{statusErr: domain.ErrOAuthNotLoggedIn}} + withCredentials(t, svc) + + _, _, err := testutil.ExecuteSubCommand(newStatusCmd()) + require.ErrorIs(t, err, domain.ErrOAuthNotLoggedIn) +} + +func TestStatusCmd_DefaultSessionSaysItIsDashboardCapable(t *testing.T) { + session := loggedInSession() + session.Tokens.Scope = "openid offline_access dashboard.session" + withService(t, &fakeService{session: session}) + + stdout, _, err := testutil.ExecuteSubCommand(newStatusCmd()) + require.NoError(t, err) + assert.Contains(t, stdout, "dashboard commands (your full role)") +} + +func TestTokenCmd_CredentialPrintsThatCredentialsToken(t *testing.T) { + svc := &credentialsFake{ + fakeService: &fakeService{accessToken: "default-token"}, + bound: map[string]*fakeService{"0123456789ab": {accessToken: "mcp-token"}}, + } + withCredentials(t, svc) + + stdout, _, err := testutil.ExecuteSubCommand(newTokenCmd(), "--credential", "0123456789ab") + require.NoError(t, err) + assert.Equal(t, "mcp-token\n", stdout) + + _, _, err = testutil.ExecuteSubCommand(newTokenCmd(), "--credential", "ffffffffffff") + require.ErrorIs(t, err, oauthlogin.ErrMCPCredentialNotFound) +} + +func TestLogoutCmd_CredentialLeavesTheDefaultSessionAndDashboardAlone(t *testing.T) { + svc := &credentialsFake{fakeService: &fakeService{}} + dash := withCredentials(t, svc) + + _, _, err := testutil.ExecuteSubCommand(newLogoutCmd(), "--credential", "0123456789ab") + require.NoError(t, err) + assert.Equal(t, []string{"0123456789ab"}, svc.loggedOut) + assert.False(t, svc.logoutCalled) + assert.False(t, dash.cleared) +} + +func TestLogoutCmd_AllLogsOutOfEveryCredential(t *testing.T) { + svc := &credentialsFake{ + fakeService: &fakeService{}, + records: []oauthlogin.MCPCredentialRecord{{ID: "0123456789ab"}, {ID: "ba9876543210"}}, + } + dash := withCredentials(t, svc) + + _, _, err := testutil.ExecuteSubCommand(newLogoutCmd(), "--all") + require.NoError(t, err) + assert.True(t, svc.logoutCalled) + assert.True(t, dash.cleared) + assert.Equal(t, []string{"0123456789ab", "ba9876543210"}, svc.loggedOut) + + _, _, err = testutil.ExecuteSubCommand(newLogoutCmd(), "--all", "--credential", "0123456789ab") + require.Error(t, err) +} + +func TestLogoutCmd_WithoutFlagsKeepsMCPOnlyCredentials(t *testing.T) { + svc := &credentialsFake{fakeService: &fakeService{}, records: []oauthlogin.MCPCredentialRecord{{ID: "0123456789ab"}}} + withCredentials(t, svc) + + _, _, err := testutil.ExecuteSubCommand(newLogoutCmd()) + require.NoError(t, err) + assert.True(t, svc.logoutCalled) + assert.Empty(t, svc.loggedOut) +} diff --git a/internal/cli/oauth/oauth_test.go b/internal/cli/oauth/oauth_test.go index 68d8986..98379e5 100644 --- a/internal/cli/oauth/oauth_test.go +++ b/internal/cli/oauth/oauth_test.go @@ -64,6 +64,18 @@ func (f *fakeService) Logout(context.Context) error { return f.logoutErr } +func (f *fakeService) ListMCPCredentials() ([]oauthlogin.MCPCredentialRecord, error) { + return nil, nil +} + +func (f *fakeService) ForMCPCredential(id string) (loginService, error) { + return nil, oauthlogin.ErrMCPCredentialNotFound +} + +func (f *fakeService) LogoutMCPCredential(context.Context, string) error { + return oauthlogin.ErrMCPCredentialNotFound +} + // fakeDashboard records the dashboard session calls login and logout make. type fakeDashboard struct { exchangeErr error diff --git a/internal/cli/oauth/status.go b/internal/cli/oauth/status.go index 89acf35..f4fb535 100644 --- a/internal/cli/oauth/status.go +++ b/internal/cli/oauth/status.go @@ -1,90 +1,150 @@ package oauth import ( + "errors" "fmt" "io" + "slices" "strings" "time" "github.com/spf13/cobra" + "github.com/nylas/cli/internal/app/oauthlogin" "github.com/nylas/cli/internal/cli/common" "github.com/nylas/cli/internal/domain" ) func newStatusCmd() *cobra.Command { - var remote bool + var ( + remote bool + credential string + ) cmd := &cobra.Command{ Use: "status", - Short: "Show the current OAuth session", - Long: `Show the stored OAuth session without printing any token. - -The access token's claims (audience, grants, scopes, expiry) are DECODED -locally and NOT VERIFIED: the CLI does not check the token's signature. They -describe what the stored token says about itself; only the resource server's -answer is authoritative. Use --verify to ask the authorization server.`, + Short: "Show the stored OAuth sessions", + Long: `Show the stored OAuth sessions without printing any token. + +The default session is the one 'nylas oauth login' stores and the dashboard +commands use. MCP-only credentials from 'nylas oauth login --mcp-only' are +listed after it, each with the id 'nylas mcp serve --auth oauth --credential' +takes, the MCP server it is for, its organization and the IAM principal it is +limited to, if any. They never give dashboard access. + +The access token's claims (audience, principal, grants, scopes, expiry) are +DECODED locally and NOT VERIFIED: the CLI does not check the token's +signature. They describe what the stored token says about itself; only the +resource server's answer is authoritative. Use --verify to ask the +authorization server.`, RunE: func(cmd *cobra.Command, _ []string) error { svc, err := createLoginServiceFn() if err != nil { return wrapOAuthError(err) } - - session, err := svc.Status() - if err != nil { - return wrapOAuthError(err) - } - - ctx, cancel := common.CreateContext() - defer cancel() - out := cmd.OutOrStdout() - expired := session.Tokens.IsExpired(time.Now()) - if expired { - _, _ = common.Yellow.Fprintln(out, "● Logged in (access token expired)") - } else { - _, _ = common.Green.Fprintln(out, "✓ Logged in") - } - - _, _ = fmt.Fprintf(out, " Issuer: %s\n", session.Issuer) - _, _ = fmt.Fprintf(out, " Client ID: %s\n", session.ClientID) - if session.Tokens.Scope != "" { - _, _ = fmt.Fprintf(out, " Scopes: %s\n", session.Tokens.Scope) - } - if !session.Tokens.ExpiresAt.IsZero() { - _, _ = fmt.Fprintf(out, " Expires: %s\n", session.Tokens.ExpiresAt.Local().Format("2006-01-02 15:04:05 MST")) - } - _, _ = fmt.Fprintf(out, " Refresh: %s\n", presentAbsent(session.Tokens.RefreshToken != "")) - printTokenClaims(out, session.Tokens.AccessToken) - if !remote { - return nil + if credential != "" { + bound, err := svc.ForMCPCredential(credential) + if err == nil { + err = showSession(cmd, bound, remote) + } + return wrapOAuthError(err) } - // --verify proves the token against the live server rather than - // trusting what is on disk. - info, err := svc.UserInfo(ctx) + records, err := svc.ListMCPCredentials() if err != nil { return wrapOAuthError(err) } - _, _ = fmt.Fprintf(out, " Subject: %s\n", info.Subject) - if info.Email != "" { - _, _ = fmt.Fprintf(out, " Email: %s (verified: %t)\n", info.Email, info.EmailVerified) - } - if info.Org != "" { - _, _ = fmt.Fprintf(out, " Org: %s\n", info.Org) + if err := showSession(cmd, svc, remote); err != nil { + if !errors.Is(err, domain.ErrOAuthNotLoggedIn) || len(records) == 0 { + return wrapOAuthError(err) + } + _, _ = fmt.Fprintln(out, "No default session (the dashboard commands are not signed in by OAuth)") } + for _, record := range records { + _, _ = fmt.Fprintf(out, "\nMCP credential %s\n", record.ID) + bound, err := svc.ForMCPCredential(record.ID) + if err == nil { + err = showSession(cmd, bound, false) + } + if err != nil { + _, _ = common.Yellow.Fprintf(out, " unavailable: %v\n", err) + } + } return nil }, } cmd.Flags().BoolVar(&remote, "verify", false, "call the userinfo endpoint to confirm the token is accepted") + cmd.Flags().StringVar(&credential, "credential", "", + "show only this MCP-only credential (an id from 'nylas oauth status')") return cmd } +// showSession prints one stored session, and with remote proves its token +// against the live server rather than trusting what is on disk. +func showSession(cmd *cobra.Command, svc loginService, remote bool) error { + session, err := svc.Status() + if err != nil { + return err + } + out := cmd.OutOrStdout() + if session.Tokens.IsExpired(time.Now()) { + _, _ = common.Yellow.Fprintln(out, "● Logged in (access token expired)") + } else { + _, _ = common.Green.Fprintln(out, "✓ Logged in") + } + + _, _ = fmt.Fprintf(out, " Access: %s\n", accessMode(session)) + _, _ = fmt.Fprintf(out, " Issuer: %s\n", session.Issuer) + _, _ = fmt.Fprintf(out, " Client ID: %s\n", session.ClientID) + if session.Tokens.Scope != "" { + _, _ = fmt.Fprintf(out, " Scopes: %s\n", session.Tokens.Scope) + } + if !session.Tokens.ExpiresAt.IsZero() { + _, _ = fmt.Fprintf(out, " Expires: %s\n", session.Tokens.ExpiresAt.Local().Format("2006-01-02 15:04:05 MST")) + } + _, _ = fmt.Fprintf(out, " Refresh: %s\n", presentAbsent(session.Tokens.RefreshToken != "")) + printTokenClaims(out, session.Tokens.AccessToken) + + if !remote { + return nil + } + ctx, cancel := common.CreateContext() + defer cancel() + info, err := svc.UserInfo(ctx) + if err != nil { + return err + } + _, _ = fmt.Fprintf(out, " Subject: %s\n", info.Subject) + if info.Email != "" { + _, _ = fmt.Fprintf(out, " Email: %s (verified: %t)\n", info.Email, info.EmailVerified) + } + if info.Org != "" { + _, _ = fmt.Fprintf(out, " Org: %s\n", info.Org) + } + return nil +} + +// accessMode says what a stored session can be used for, from its granted +// scopes and the principal its token names. +func accessMode(session *oauthlogin.Session) string { + if claims, err := domain.DecodeOAuthAccessToken(session.Tokens.AccessToken); err == nil && claims.Principal != nil { + return "MCP only, limited to an IAM principal (no dashboard access)" + } + if slices.Contains(strings.Fields(session.Tokens.Scope), domain.OAuthScopeDashboardSession) { + return "dashboard commands (your full role) and the MCP server" + } + if session.Resource != "" { + return "MCP only (no dashboard access)" + } + return "identity only (no dashboard access)" +} + // printTokenClaims shows what the access token says about itself. It never // prints the token, and a token that does not decode is reported, not fatal: // the session is still usable, the server is what reads the token. @@ -101,6 +161,10 @@ func printTokenClaims(out io.Writer, accessToken string) { audience = strings.Join(claims.Audience, ", ") } _, _ = fmt.Fprintf(out, " Audience: %s\n", audience) + if claims.Org != "" { + _, _ = fmt.Fprintf(out, " Org: %s\n", claims.Org) + } + _, _ = fmt.Fprintf(out, " Principal: %s\n", describePrincipal(claims.Principal)) scopes := "(none)" if claims.Scope != "" { diff --git a/internal/cli/oauth/token.go b/internal/cli/oauth/token.go index 71d8c29..c7fdf6f 100644 --- a/internal/cli/oauth/token.go +++ b/internal/cli/oauth/token.go @@ -9,17 +9,22 @@ import ( ) func newTokenCmd() *cobra.Command { - return &cobra.Command{ + var credential string + cmd := &cobra.Command{ Use: "token", Short: "Print a valid access token, refreshing it if needed", Long: `Print the current access token. If the stored token has expired it is refreshed first, and the rotated -refresh token replaces the old one in the keyring.`, +refresh token replaces the old one in the keyring. --credential prints the +token of an MCP-only credential instead of the default session's.`, Example: ` # Call an OAuth-protected endpoint curl -H "Authorization: Bearer $(nylas oauth token)" https://example/resource`, RunE: func(cmd *cobra.Command, _ []string) error { svc, err := createLoginServiceFn() + if err == nil { + svc, err = forCredential(svc, credential) + } if err != nil { return wrapOAuthError(err) } @@ -36,4 +41,7 @@ refresh token replaces the old one in the keyring.`, return nil }, } + cmd.Flags().StringVar(&credential, "credential", "", + "print the token of this MCP-only credential (an id from 'nylas oauth status')") + return cmd } diff --git a/internal/domain/mcp_auth.go b/internal/domain/mcp_auth.go index dab7a1c..78e3d88 100644 --- a/internal/domain/mcp_auth.go +++ b/internal/domain/mcp_auth.go @@ -143,6 +143,16 @@ func MCPOAuthScopes() []string { } } +// MCPOnlyOAuthScopes is what `nylas oauth login --mcp-only` requests: +// MCPOAuthScopes without dashboard.session. The consent screen offers an IAM +// principal only for a request without it, and a credential from this login +// never signs the dashboard commands in. +func MCPOnlyOAuthScopes() []string { + return slices.DeleteFunc(MCPOAuthScopes(), func(scope string) bool { + return scope == OAuthScopeDashboardSession + }) +} + // ErrMCPCredentialNotRenewable is returned by a credential source that has // nothing to renew — an API key is what it is. var ErrMCPCredentialNotRenewable = errors.New("credential cannot be renewed") diff --git a/internal/domain/oauth.go b/internal/domain/oauth.go index 06e6cf7..bb0e6a6 100644 --- a/internal/domain/oauth.go +++ b/internal/domain/oauth.go @@ -187,6 +187,15 @@ type OAuthError struct { Code string `json:"error"` Description string `json:"error_description"` StatusCode int `json:"-"` + // RetryAfter is the response's Retry-After delay, zero when absent. + RetryAfter time.Duration `json:"-"` +} + +// IsTemporarilyUnavailable reports the 503 the authorization server answers +// when a read it depends on failed. The refresh token was not rotated, so +// the same request may be retried; it is not a reason to sign the user out. +func (e *OAuthError) IsTemporarilyUnavailable() bool { + return e.StatusCode == 503 } func (e *OAuthError) Error() string { diff --git a/internal/domain/oauth_client.go b/internal/domain/oauth_client.go index 64405e9..abca531 100644 --- a/internal/domain/oauth_client.go +++ b/internal/domain/oauth_client.go @@ -84,13 +84,40 @@ type OAuthTokenGrant struct { // the resource server is what verifies it. Use them to describe a session // and to route a request, never to decide whether something is allowed. type OAuthAccessTokenClaims struct { - Subject string `json:"sub"` - Audience []string `json:"-"` - Scope string `json:"scope"` - Org string `json:"org"` - ClientID string `json:"client_id"` - Grants []OAuthTokenGrant `json:"grants"` - ExpiresAt time.Time `json:"-"` + Subject string `json:"sub"` + Audience []string `json:"-"` + Scope string `json:"scope"` + Org string `json:"org"` + ClientID string `json:"client_id"` + Grants []OAuthTokenGrant `json:"grants"` + // Permissions are the IAM permission ids the token authorizes. + Permissions []string `json:"permissions"` + // Principal is set only on a token narrowed by an IAM principal. Such a + // token is MCP-only: it must never become a dashboard session. + Principal *OAuthTokenPrincipal `json:"principal,omitempty"` + ExpiresAt time.Time `json:"-"` +} + +// OAuthTokenPrincipal is an access token's principal claim: the IAM +// principal the token is narrowed by, and the region it lives in. +type OAuthTokenPrincipal struct { + ID string `json:"id"` + Region string `json:"region"` +} + +var oauthPrincipalIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{21}$`) + +// ErrOAuthPrincipalClaim is returned for a principal claim that is not in +// the shape the authorization server mints. +var ErrOAuthPrincipalClaim = errors.New("access token has a malformed principal claim") + +// Validate checks the claim's shape. The CLI files a credential under the +// principal id, so a value it would not mint is refused rather than used. +func (p *OAuthTokenPrincipal) Validate() error { + if !oauthPrincipalIDPattern.MatchString(p.ID) || (p.Region != "us" && p.Region != "eu") { + return ErrOAuthPrincipalClaim + } + return nil } // Scopes returns the space-delimited scope claim as a list. diff --git a/internal/ports/secrets.go b/internal/ports/secrets.go index 6aff72c..407fcb1 100644 --- a/internal/ports/secrets.go +++ b/internal/ports/secrets.go @@ -57,4 +57,8 @@ const ( KeyOAuthIDToken = "oauth_id_token" KeyOAuthExpiresAt = "oauth_expires_at" KeyOAuthScope = "oauth_scope" + // KeyOAuthMCPCredentials indexes the MCP-only credentials stored beside + // the default session (`nylas oauth login --mcp-only`). It holds no token: + // each credential's own keys are "oauth_mcp__". + KeyOAuthMCPCredentials = "oauth_mcp_credentials" )