Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion internal/adapters/dashboard/account_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,17 @@ 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,
}
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 {
Expand Down
29 changes: 28 additions & 1 deletion internal/adapters/dashboard/account_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions internal/adapters/dashboard/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions internal/app/dashboard/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/dashboard/exports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/dashboard/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/dashboard/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
}
46 changes: 38 additions & 8 deletions internal/cli/dashboard/sso.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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, "")
},
}

Expand All @@ -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]
Expand All @@ -85,6 +90,29 @@ 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 == "" {
// 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
}
}
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)
Expand All @@ -95,7 +123,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 {
Expand Down Expand Up @@ -255,10 +283,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",
)
}
}
1 change: 1 addition & 0 deletions internal/domain/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions internal/ports/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading