From 3df93f117015e32f7ff5ef5e78b01f7f7ff9b574 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 7 Jul 2026 15:05:47 -0400 Subject: [PATCH 1/2] [TW-5801] feat(dashboard): add SAML Enterprise SSO login via --provider saml - Add saml_SSO login type and map 'saml' provider in sso login - Add --email flag for home-realm discovery; prompt interactively when omitted - Thread email through SSOStart (ports, adapter, app service, mock) - Reject 'sso register --provider saml' with guidance (accounts are JIT-provisioned on first SAML login) - Server flow: dashboard-account mints a device-style flow; browser confirms the one-time code and completes SP-initiated SAML; poll returns DPoP-bound tokens Server-side counterpart: nylas/dashboard-v3#2026 Co-Authored-By: Claude Fable 5 --- internal/adapters/dashboard/account_client.go | 5 ++- .../adapters/dashboard/account_client_test.go | 29 +++++++++++- internal/adapters/dashboard/mock.go | 6 +-- internal/app/dashboard/auth_service.go | 7 +-- internal/cli/dashboard/exports.go | 2 +- internal/cli/dashboard/login.go | 2 +- internal/cli/dashboard/register.go | 2 +- internal/cli/dashboard/sso.go | 44 +++++++++++++++---- internal/domain/dashboard.go | 1 + internal/ports/dashboard.go | 5 ++- 10 files changed, 82 insertions(+), 21 deletions(-) diff --git a/internal/adapters/dashboard/account_client.go b/internal/adapters/dashboard/account_client.go index 316654e..6001895 100644 --- a/internal/adapters/dashboard/account_client.go +++ b/internal/adapters/dashboard/account_client.go @@ -143,7 +143,7 @@ func (c *AccountClient) Logout(ctx context.Context, userToken, orgToken string) } // SSOStart initiates an SSO device authorization flow. -func (c *AccountClient) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) { +func (c *AccountClient) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool, email string) (*domain.DashboardSSOStartResponse, error) { body := map[string]any{ "loginType": loginType, "mode": mode, @@ -151,6 +151,9 @@ func (c *AccountClient) SSOStart(ctx context.Context, loginType, mode string, pr if mode == "register" { body["privacyPolicyAccepted"] = privacyPolicyAccepted } + if email != "" { + body["email"] = email + } var result domain.DashboardSSOStartResponse if err := c.doPost(ctx, "/auth/cli/sso/start", body, nil, "", &result); err != nil { diff --git a/internal/adapters/dashboard/account_client_test.go b/internal/adapters/dashboard/account_client_test.go index 1e2033b..c1ecc16 100644 --- a/internal/adapters/dashboard/account_client_test.go +++ b/internal/adapters/dashboard/account_client_test.go @@ -197,11 +197,38 @@ func TestAccountClientPublicEndpoints(t *testing.T) { }) }, run: func(t *testing.T, client *AccountClient) { - resp, err := client.SSOStart(context.Background(), domain.SSOLoginTypeGoogle, "register", true) + resp, err := client.SSOStart(context.Background(), domain.SSOLoginTypeGoogle, "register", true, "") require.NoError(t, err) assert.Equal(t, "flow-1", resp.FlowID) }, }, + { + name: "start SAML SSO flow sends email for home-realm discovery", + handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, rawBody []byte, body map[string]any) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/auth/cli/sso/start", r.URL.Path) + assert.Equal(t, "saml_SSO", body["loginType"]) + assert.Equal(t, "login", body["mode"]) + assert.Equal(t, "user@acme.com", body["email"]) + _, hasPrivacy := body["privacyPolicyAccepted"] + assert.False(t, hasPrivacy) + + writeDashboardEnvelope(t, w, map[string]any{ + "flowId": "flow-saml", + "verificationUri": "https://accounts.example.com/pages/cli-saml", + "verificationUriComplete": "https://accounts.example.com/pages/cli-saml?code=ABCD2345", + "userCode": "ABCD2345", + "expiresIn": 600, + "interval": 5, + }) + }, + run: func(t *testing.T, client *AccountClient) { + resp, err := client.SSOStart(context.Background(), domain.SSOLoginTypeSAML, "login", false, "user@acme.com") + require.NoError(t, err) + assert.Equal(t, "flow-saml", resp.FlowID) + assert.Equal(t, "ABCD2345", resp.UserCode) + }, + }, { name: "get current session", handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, rawBody []byte, _ map[string]any) { diff --git a/internal/adapters/dashboard/mock.go b/internal/adapters/dashboard/mock.go index 5cc9a1d..be5fb07 100644 --- a/internal/adapters/dashboard/mock.go +++ b/internal/adapters/dashboard/mock.go @@ -15,7 +15,7 @@ type MockAccountClient struct { LoginMFAFn func(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) RefreshFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) LogoutFn func(ctx context.Context, userToken, orgToken string) error - SSOStartFn func(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) + SSOStartFn func(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool, email string) (*domain.DashboardSSOStartResponse, error) SSOPollFn func(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) GetCurrentSessionFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error) SwitchOrgFn func(ctx context.Context, orgPublicID, userToken, orgToken string) (*domain.DashboardSwitchOrgResponse, error) @@ -50,8 +50,8 @@ func (m *MockAccountClient) Refresh(ctx context.Context, userToken, orgToken str func (m *MockAccountClient) Logout(ctx context.Context, userToken, orgToken string) error { return m.LogoutFn(ctx, userToken, orgToken) } -func (m *MockAccountClient) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) { - return m.SSOStartFn(ctx, loginType, mode, privacyPolicyAccepted) +func (m *MockAccountClient) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool, email string) (*domain.DashboardSSOStartResponse, error) { + return m.SSOStartFn(ctx, loginType, mode, privacyPolicyAccepted, email) } func (m *MockAccountClient) SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) { return m.SSOPollFn(ctx, flowID, orgPublicID) diff --git a/internal/app/dashboard/auth_service.go b/internal/app/dashboard/auth_service.go index cc2fa88..99c9df7 100644 --- a/internal/app/dashboard/auth_service.go +++ b/internal/app/dashboard/auth_service.go @@ -129,9 +129,10 @@ func (s *AuthService) Logout(ctx context.Context) error { return s.clearTokens() } -// SSOStart initiates an SSO device authorization flow. -func (s *AuthService) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) { - return s.account.SSOStart(ctx, loginType, mode, privacyPolicyAccepted) +// SSOStart initiates an SSO device authorization flow. email is only used for +// saml_SSO home-realm discovery. +func (s *AuthService) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool, email string) (*domain.DashboardSSOStartResponse, error) { + return s.account.SSOStart(ctx, loginType, mode, privacyPolicyAccepted, email) } // SSOPoll polls the SSO device flow. On completion, stores tokens. diff --git a/internal/cli/dashboard/exports.go b/internal/cli/dashboard/exports.go index c1e1614..58c3848 100644 --- a/internal/cli/dashboard/exports.go +++ b/internal/cli/dashboard/exports.go @@ -17,7 +17,7 @@ func CreateAppService() (*dashboardapp.AppService, error) { // RunSSO executes the SSO device-code flow (exported for setup wizard). func RunSSO(provider, mode string, privacyAccepted bool) error { - return runSSO(provider, mode, privacyAccepted, "") + return runSSO(provider, mode, privacyAccepted, "", "") } // AcceptPrivacyPolicy prompts for privacy policy acceptance (exported for setup wizard). diff --git a/internal/cli/dashboard/login.go b/internal/cli/dashboard/login.go index 2813fb8..14667b0 100644 --- a/internal/cli/dashboard/login.go +++ b/internal/cli/dashboard/login.go @@ -43,7 +43,7 @@ Choose SSO (recommended) or email/password. Pass a flag to skip the menu.`, switch method { case methodGoogle, methodMicrosoft, methodGitHub: - return runSSO(method, "login", false, orgPublicID) + return runSSO(method, "login", false, "", orgPublicID) case methodEmailPassword: return runEmailLogin(userFlag, passFlag, orgPublicID) default: diff --git a/internal/cli/dashboard/register.go b/internal/cli/dashboard/register.go index d584a32..7852c1e 100644 --- a/internal/cli/dashboard/register.go +++ b/internal/cli/dashboard/register.go @@ -59,5 +59,5 @@ func runSSORegister(provider string, acceptedPrivacyPolicy bool) error { if err := acceptPrivacyPolicy(acceptedPrivacyPolicy); err != nil { return err } - return runSSO(provider, "register", true) + return runSSO(provider, "register", true, "") } diff --git a/internal/cli/dashboard/sso.go b/internal/cli/dashboard/sso.go index 206d2f3..03d2060 100644 --- a/internal/cli/dashboard/sso.go +++ b/internal/cli/dashboard/sso.go @@ -31,20 +31,25 @@ func newSSOCmd() *cobra.Command { } func newSSOLoginCmd() *cobra.Command { - var provider string + var ( + provider string + email string + ) cmd := &cobra.Command{ Use: "login", Short: "Log in via SSO", Example: ` nylas dashboard sso login --provider google nylas dashboard sso login --provider microsoft - nylas dashboard sso login --provider github`, + nylas dashboard sso login --provider github + nylas dashboard sso login --provider saml --email you@company.com`, RunE: func(cmd *cobra.Command, args []string) error { - return runSSO(provider, "login", false) + return runSSO(provider, "login", false, email) }, } - cmd.Flags().StringVarP(&provider, "provider", "p", "google", "SSO provider (google, microsoft, github)") + cmd.Flags().StringVarP(&provider, "provider", "p", "google", "SSO provider (google, microsoft, github, saml)") + cmd.Flags().StringVar(&email, "email", "", "Work email for SAML SSO home-realm discovery (saml provider only)") return cmd } @@ -64,7 +69,7 @@ func newSSORegisterCmd() *cobra.Command { if err := acceptPrivacyPolicy(acceptPrivacyPolicyFlag); err != nil { return err } - return runSSO(provider, "register", true) + return runSSO(provider, "register", true, "") }, } @@ -74,7 +79,7 @@ func newSSORegisterCmd() *cobra.Command { return cmd } -func runSSO(provider, mode string, privacyPolicyAccepted bool, orgPublicIDs ...string) error { +func runSSO(provider, mode string, privacyPolicyAccepted bool, email string, orgPublicIDs ...string) error { orgPublicID := "" if len(orgPublicIDs) > 0 { orgPublicID = orgPublicIDs[0] @@ -85,6 +90,27 @@ func runSSO(provider, mode string, privacyPolicyAccepted bool, orgPublicIDs ...s return err } + if loginType == domain.SSOLoginTypeSAML { + if mode == "register" { + return dashboardError( + "SAML SSO does not have a separate registration step", + "Accounts are provisioned on first login: nylas dashboard sso login --provider saml --email you@company.com", + ) + } + if email == "" { + email, err = common.InputPrompt("Work email", "you@company.com") + if err != nil { + return err + } + } + if email == "" { + return dashboardError( + "a work email is required for SAML SSO", + "Pass --email you@company.com to discover your organization's SSO", + ) + } + } + authSvc, _, err := createAuthService() if err != nil { return wrapDashboardError(err) @@ -95,7 +121,7 @@ func runSSO(provider, mode string, privacyPolicyAccepted bool, orgPublicIDs ...s var resp *domain.DashboardSSOStartResponse err = common.RunWithSpinner("Starting SSO...", func() error { - resp, err = authSvc.SSOStart(ctx, loginType, mode, privacyPolicyAccepted) + resp, err = authSvc.SSOStart(ctx, loginType, mode, privacyPolicyAccepted, email) return err }) if err != nil { @@ -255,10 +281,12 @@ func mapProvider(provider string) (string, error) { return domain.SSOLoginTypeMicrosoft, nil case "github": return domain.SSOLoginTypeGitHub, nil + case "saml": + return domain.SSOLoginTypeSAML, nil default: return "", dashboardError( fmt.Sprintf("unsupported SSO provider: %s", provider), - "Use one of: google, microsoft, github", + "Use one of: google, microsoft, github, saml", ) } } diff --git a/internal/domain/dashboard.go b/internal/domain/dashboard.go index af07da0..caae5ba 100644 --- a/internal/domain/dashboard.go +++ b/internal/domain/dashboard.go @@ -85,6 +85,7 @@ const ( SSOLoginTypeGoogle = "google_SSO" SSOLoginTypeMicrosoft = "microsoft_SSO" SSOLoginTypeGitHub = "github_SSO" + SSOLoginTypeSAML = "saml_SSO" ) // GatewayApplication is an application as returned by the dashboard API gateway. diff --git a/internal/ports/dashboard.go b/internal/ports/dashboard.go index f04fbdc..17e818a 100644 --- a/internal/ports/dashboard.go +++ b/internal/ports/dashboard.go @@ -30,8 +30,9 @@ type DashboardAccountClient interface { // Logout invalidates the session tokens. Logout(ctx context.Context, userToken, orgToken string) error - // SSOStart initiates an SSO device authorization flow. - SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) + // SSOStart initiates an SSO device authorization flow. email is required for + // saml_SSO (used for home-realm discovery) and ignored for other providers. + SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool, email string) (*domain.DashboardSSOStartResponse, error) // SSOPoll polls the SSO device flow for completion. SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) From ceedb68ca2c741759275de7232ad5ed93d285221 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 7 Jul 2026 15:15:50 -0400 Subject: [PATCH 2/2] [TW-5801] fix(dashboard): don't let the SAML email prompt default to the placeholder InputPrompt returns the placeholder in non-TTY runs and on empty submit, so 'you@company.com' became the HRD email. Use an empty placeholder so those paths fall through to the required-email error. Co-Authored-By: Claude Fable 5 --- internal/cli/dashboard/sso.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/cli/dashboard/sso.go b/internal/cli/dashboard/sso.go index 03d2060..7e74274 100644 --- a/internal/cli/dashboard/sso.go +++ b/internal/cli/dashboard/sso.go @@ -98,7 +98,9 @@ func runSSO(provider, mode string, privacyPolicyAccepted bool, email string, org ) } if email == "" { - email, err = common.InputPrompt("Work email", "you@company.com") + // Empty placeholder: InputPrompt echoes the placeholder back in + // non-TTY runs and on empty submit, which must not become the email. + email, err = common.InputPrompt("Work email (e.g. you@company.com)", "") if err != nil { return err }