From 30ca46bc80772a86115d47df88f79ba81c6c63ec Mon Sep 17 00:00:00 2001 From: Daniel Gordos Date: Fri, 30 Oct 2026 01:10:29 +0000 Subject: [PATCH] fix(auth): [FDE-2844] sign in through the router and discover the tenant data host Co-Authored-By: Claude Opus 5.5 --- README.md | 28 ++- cmd/cli/cmd/auth.go | 139 +++++++---- cmd/cli/cmd/auth_test.go | 44 ++++ cmd/cli/cmd/mcp.go | 43 ++-- cmd/cli/cmd/open.go | 11 +- cmd/cli/cmd/root.go | 12 +- cmd/cli/cmd/runtime_auth.go | 77 ++++-- cmd/cli/cmd/runtime_auth_test.go | 230 +++++++++++++++++- cmd/cli/cmd/whoami.go | 18 +- install.sh | 2 +- internal/auth/credentials.go | 17 ++ internal/auth/installations.go | 151 ++++++++++++ internal/auth/installations_test.go | 116 +++++++++ internal/auth/login.go | 115 ++++++++- internal/auth/login_router_test.go | 355 ++++++++++++++++++++++++++++ internal/auth/router.go | 99 ++++++++ internal/auth/router_test.go | 78 ++++++ internal/lib/get_token.go | 6 + internal/lib/get_token_test.go | 16 ++ internal/lib/urls.go | 7 + internal/lib/urls_test.go | 40 ++++ internal/wizard/steps.go | 29 ++- internal/wizard/wizard_test.go | 63 +++++ 23 files changed, 1579 insertions(+), 117 deletions(-) create mode 100644 cmd/cli/cmd/auth_test.go create mode 100644 internal/auth/installations.go create mode 100644 internal/auth/installations_test.go create mode 100644 internal/auth/login_router_test.go create mode 100644 internal/auth/router.go create mode 100644 internal/auth/router_test.go diff --git a/README.md b/README.md index 11d9b96..3148b3c 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,24 @@ nullify findings ### Interactive (SSO/IdP) ```sh -nullify auth login --host api.acme.nullify.ai +nullify auth login ``` -Opens your browser for single sign-on. Tokens are stored locally and refreshed automatically. +Opens your browser to sign in through nullify.ai with your identity provider (for example Microsoft Entra ID or GitLab). After sign-in, the CLI selects the installation that holds your data: it uses the only one automatically, asks you to pick one in a terminal, or takes `--installation ` in scripts. Tokens are stored locally and refreshed automatically. + +If you sign in through nullify.ai, do not set `--host` or `NULLIFY_HOST` to your instance; run `nullify auth login` and the CLI routes data requests to the selected installation. An explicit instance host uses only the credentials stored for that host. + +To sign in to your own Nullify instance instead, pass its host: + +```sh +nullify auth login --host acme.nullify.ai +``` + +Switch to another installation after signing in through nullify.ai: + +```sh +nullify auth switch --installation other-org +``` ### Environment Variables @@ -135,7 +149,7 @@ nullify auth switch | `nullify auth logout` | Clear stored credentials for a host | | `nullify auth status` | Show auth state, host, and token expiry | | `nullify auth token` | Print raw access token to stdout (pipe-friendly) | -| `nullify auth switch` | Switch active instance or list configured instances | +| `nullify auth switch` | Switch active instance or installation, or list configured instances | | `nullify auth config` | Print current CLI config as JSON | ### Scanning @@ -292,14 +306,16 @@ Scope findings to a specific repository with `--repo`: The CLI stores configuration at `~/.nullify/config.json`. Host resolution priority: 1. `--host` flag -2. Config file (`~/.nullify/config.json`) -3. `NULLIFY_HOST` environment variable +2. `NULLIFY_HOST` environment variable +3. Config file (`~/.nullify/config.json`) + +When the host is `nullify.ai`, the CLI signs in and refreshes tokens on nullify.ai and sends data requests to the selected installation's instance. GitHub Actions token exchange (`--github-token`) needs your instance host, not `nullify.ai`. ### Global Flags | Flag | Description | Default | |------|-------------|---------| -| `--host` | Nullify API instance (e.g., `api.acme.nullify.ai`) | From config | +| `--host` | Nullify instance (e.g., `acme.nullify.ai`), or `nullify.ai` to sign in through the Nullify router | From config | | `--output, -o` | Output format (`json`, `table`, `yaml`) | `json` | | `--verbose, -v` | Enable verbose logging | `false` | | `--debug, -d` | Enable debug logging | `false` | diff --git a/cmd/cli/cmd/auth.go b/cmd/cli/cmd/auth.go index fd46259..05a1e4f 100644 --- a/cmd/cli/cmd/auth.go +++ b/cmd/cli/cmd/auth.go @@ -3,8 +3,10 @@ package cmd import ( "encoding/json" "fmt" + "maps" "os" "os/signal" + "slices" "syscall" "time" @@ -22,43 +24,58 @@ var authCmd = &cobra.Command{ var loginCmd = &cobra.Command{ Use: "login", Short: "Log in to Nullify", - Long: "Authenticate with your Nullify instance. Opens your browser to log in with your identity provider.", + Long: "Authenticate with Nullify. Opens your browser to log in with your identity provider. Without --host or a configured host, signs in through nullify.ai and selects an installation.", RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() - loginHost := host - - // If no host from flag, try config file - if loginHost == "" { - cfg, err := auth.LoadConfig() - if err == nil && cfg.Host != "" { - loginHost = cfg.Host - } - } - - // If still no host, prompt (only when running interactively). - if loginHost == "" { - if !stdinIsTTY() { - return fmt.Errorf("not a terminal; pass --host .nullify.ai") - } - fmt.Print("Enter your Nullify instance (e.g., acme.nullify.ai): ") - _, _ = fmt.Scanln(&loginHost) + sanitizedHost, err := resolveLoginHost() + if err != nil { + return err } - sanitizedHost, err := lib.SanitizeNullifyHost(loginHost) - if err != nil { - return fmt.Errorf("invalid host %q - must be in the format .nullify.ai", loginHost) + installation, _ := cmd.Flags().GetString("installation") + if installation != "" && !auth.IsRouterHost(sanitizedHost) { + return fmt.Errorf("--installation applies only when signing in through nullify.ai") } - if err := auth.Login(ctx, sanitizedHost); err != nil { + if err := auth.Login(ctx, sanitizedHost, installationPicker(installation)); err != nil { return fmt.Errorf("login failed: %w", err) } return nil }, } +// resolveLoginHost returns the --host flag, else the configured host, else the router host. +func resolveLoginHost() (string, error) { + loginHost := host + if loginHost == "" { + cfg, err := auth.LoadConfig() + if err == nil && cfg.Host != "" { + loginHost = cfg.Host + } + } + if loginHost == "" { + loginHost = auth.RouterHost + } + + sanitizedHost, err := lib.SanitizeNullifyHost(loginHost) + if err != nil { + return "", fmt.Errorf("invalid host %q - must be nullify.ai or .nullify.ai", loginHost) + } + return sanitizedHost, nil +} + +func installationPicker(name string) auth.InstallationPicker { + return auth.InstallationPicker{ + Name: name, + Interactive: stdinIsTTY(), + In: os.Stdin, + Out: os.Stdout, + } +} + var logoutCmd = &cobra.Command{ Use: "logout", Short: "Log out of Nullify", @@ -85,12 +102,12 @@ var statusCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { cfg, err := auth.LoadConfig() if err != nil { - fmt.Println("Not configured. Run 'nullify auth login --host .nullify.ai' to get started.") + fmt.Println("Not configured. Run 'nullify auth login' to get started.") return nil } if cfg.Host == "" { - fmt.Println("No host configured. Run 'nullify auth login --host .nullify.ai'") + fmt.Println("No host configured. Run 'nullify auth login'") return nil } @@ -108,6 +125,10 @@ var statusCmd = &cobra.Command{ return nil } + if auth.IsRouterHost(cfg.Host) { + printInstallation(hostCreds) + } + if hostCreds.ExpiresAt > 0 { expiresAt := time.Unix(hostCreds.ExpiresAt, 0) if time.Now().After(expiresAt) { @@ -122,6 +143,14 @@ var statusCmd = &cobra.Command{ }, } +func printInstallation(hostCreds auth.HostCredentials) { + if hostCreds.DataHost == "" { + fmt.Println("Installation: none selected - run 'nullify auth switch --installation '") + return + } + fmt.Printf("Installation: %s (%s)\n", hostCreds.Installation, hostCreds.DataHost) +} + var tokenCmd = &cobra.Command{ Use: "token", Short: "Print access token to stdout", @@ -146,32 +175,29 @@ var tokenCmd = &cobra.Command{ var switchCmd = &cobra.Command{ Use: "switch", - Short: "Switch between configured hosts", - Long: "Switch the default host when multiple Nullify instances are configured.", + Short: "Switch between configured hosts or installations", + Long: "Switch the default host when multiple Nullify instances are configured, or select another installation with --installation when signed in through nullify.ai.", RunE: func(cmd *cobra.Command, args []string) error { switchHost, _ := cmd.Flags().GetString("host") if switchHost == "" && host != "" { switchHost = host } - if switchHost == "" { - // List available hosts - creds, err := auth.LoadCredentials() - if err != nil || len(creds) == 0 { - fmt.Println("No configured hosts. Run 'nullify auth login --host .nullify.ai'") - return nil + installation, _ := cmd.Flags().GetString("installation") + if installation != "" { + if switchHost != "" && !auth.IsRouterHost(switchHost) { + return fmt.Errorf("--installation applies only to nullify.ai") } - - cfg, _ := auth.LoadConfig() - fmt.Println("Configured hosts:") - for h := range creds { - marker := " " - if cfg != nil && auth.CredentialKey(cfg.Host) == h { - marker = "* " - } - fmt.Printf("%s%s\n", marker, h) + install, err := auth.SwitchInstallation(cmd.Context(), installation) + if err != nil { + return fmt.Errorf("switch installation failed: %w", err) } - fmt.Println("\nUse 'nullify auth switch --host ' to switch.") + fmt.Printf("Switched to installation %s (%s)\n", install.Name, install.DataHost) + return nil + } + + if switchHost == "" { + listConfiguredHosts() return nil } @@ -195,6 +221,32 @@ var switchCmd = &cobra.Command{ }, } +func listConfiguredHosts() { + creds, err := auth.LoadCredentials() + if err != nil || len(creds) == 0 { + fmt.Println("No configured hosts. Run 'nullify auth login'") + return + } + + cfg, _ := auth.LoadConfig() + fmt.Println("Configured hosts:") + for _, h := range slices.Sorted(maps.Keys(creds)) { + marker := " " + if cfg != nil && auth.CredentialKey(cfg.Host) == h { + marker = "* " + } + fmt.Printf("%s%s%s\n", marker, h, installationLabel(creds[h])) + } + fmt.Println("\nUse 'nullify auth switch --host ' to switch hosts, or 'nullify auth switch --installation ' to switch installations on nullify.ai.") +} + +func installationLabel(hostCreds auth.HostCredentials) string { + if hostCreds.DataHost == "" { + return "" + } + return fmt.Sprintf(" (installation %s, %s)", hostCreds.Installation, hostCreds.DataHost) +} + var configShowCmd = &cobra.Command{ Use: "config", Short: "Show current configuration", @@ -220,6 +272,9 @@ func init() { authCmd.AddCommand(tokenCmd) authCmd.AddCommand(switchCmd) authCmd.AddCommand(configShowCmd) + + loginCmd.Flags().String("installation", "", "Installation to use when signing in through nullify.ai") + switchCmd.Flags().String("installation", "", "Installation to select for the nullify.ai sign-in") } func resolveHostForAuth() (string, error) { diff --git a/cmd/cli/cmd/auth_test.go b/cmd/cli/cmd/auth_test.go new file mode 100644 index 0000000..40d5da3 --- /dev/null +++ b/cmd/cli/cmd/auth_test.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "testing" + + "github.com/nullify-platform/cli/internal/auth" + "github.com/stretchr/testify/require" +) + +func TestResolveLoginHost(t *testing.T) { + tests := []struct { + name string + flagHost string + configHost string + want string + wantErr bool + }{ + {name: "defaults to the router", want: "nullify.ai"}, + {name: "configured tenant", configHost: "acme.nullify.ai", want: "acme.nullify.ai"}, + {name: "configured api tenant", configHost: "api.acme.nullify.ai", want: "acme.nullify.ai"}, + {name: "flag wins over config", flagHost: "beta.nullify.ai", configHost: "acme.nullify.ai", want: "beta.nullify.ai"}, + {name: "router alias flag", flagHost: "https://app.nullify.ai", configHost: "acme.nullify.ai", want: "nullify.ai"}, + {name: "api router alias", flagHost: "api.nullify.ai", want: "nullify.ai"}, + {name: "invalid flag", flagHost: "acme.example.com", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + resetCommandFlags(t) + host = tt.flagHost + if tt.configHost != "" { + require.NoError(t, auth.SaveConfig(&auth.Config{Host: tt.configHost})) + } + + got, err := resolveLoginHost() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/cli/cmd/mcp.go b/cmd/cli/cmd/mcp.go index 11807d5..55e5a6f 100644 --- a/cmd/cli/cmd/mcp.go +++ b/cmd/cli/cmd/mcp.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "strings" @@ -53,24 +54,10 @@ var mcpServeCmd = &cobra.Command{ return fmt.Errorf("invalid --tools value %q. Valid values: %s", toolsFlag, strings.Join(validSets, ", ")) } - // Drive the generated API client through a refreshing+retrying transport - // so long-running MCP sessions keep working as the token rotates. Tenant - // scoping (owner/installation IDs, --repo) rides along as default params. - tokenProvider := func() (string, error) { - return lib.GetNullifyToken(ctx, authCtx.Host, nullifyToken, githubToken) - } - refreshProvider := func() (string, error) { - return lib.RefreshNullifyToken(ctx, authCtx.Host, nullifyToken, githubToken) - } - httpClient, clientErr := client.NewRefreshingHTTPClient(authCtx.Host, tokenProvider, refreshProvider) - if clientErr != nil { - return fmt.Errorf("failed to create client: %w", clientErr) + apiClient, err := newMCPAPIClient(ctx, authCtx, queryParams) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) } - // Empty token literal is intentional: the refreshing transport's - // RoundTrip injects (and rotates) the Authorization header after - // api.Client.do sets it, so the static value in api.Client is - // overridden on every request. - apiClient := api.NewClient(authCtx.Host, "", queryParams, api.WithHTTPClient(httpClient)) err = mcp.ServeWithClient(ctx, apiClient, toolSet) if err != nil { @@ -88,6 +75,28 @@ func init() { mcpServeCmd.Flags().String("tools", "default", "Tool set to register (default, all, minimal, findings, admin)") } +// newMCPAPIClient drives the API client through a refreshing, retrying transport so long MCP sessions survive token rotation. +func newMCPAPIClient(ctx context.Context, authCtx *commandAuthContext, queryParams map[string]string) (*api.Client, error) { + tokenProvider, refreshProvider := mcpTokenProviders(ctx, authCtx.AuthHost) + httpClient, err := client.NewRefreshingHTTPClient(authCtx.DataHost, tokenProvider, refreshProvider) + if err != nil { + return nil, err + } + + // The refreshing transport sets the Authorization header on every request, so the static token stays empty. + return api.NewClient(authCtx.DataHost, "", queryParams, api.WithHTTPClient(httpClient)), nil +} + +func mcpTokenProviders(ctx context.Context, authHost string) (client.TokenProvider, client.TokenProvider) { + tokenProvider := func() (string, error) { + return lib.GetNullifyToken(ctx, authHost, nullifyToken, githubToken) + } + refreshProvider := func() (string, error) { + return lib.RefreshNullifyToken(ctx, authHost, nullifyToken, githubToken) + } + return tokenProvider, refreshProvider +} + // resolveRepo determines the repository name from the flag or git config. func resolveRepo(flagValue string) string { if flagValue != "" { diff --git a/cmd/cli/cmd/open.go b/cmd/cli/cmd/open.go index 1a67d73..1c7b580 100644 --- a/cmd/cli/cmd/open.go +++ b/cmd/cli/cmd/open.go @@ -19,9 +19,7 @@ var openCmd = &cobra.Command{ if err != nil { return err } - // Strip "api." prefix to get the dashboard URL - dashboardHost := strings.TrimPrefix(openHost, "api.") - url := "https://" + dashboardHost + url := dashboardURL(openHost) if !quiet { fmt.Fprintf(os.Stderr, "Opening %s...\n", url) @@ -44,3 +42,10 @@ var openCmd = &cobra.Command{ func init() { rootCmd.AddCommand(openCmd) } + +func dashboardURL(commandHost string) string { + if auth.IsRouterHost(commandHost) { + return auth.RouterAppURL + } + return "https://" + strings.TrimPrefix(commandHost, "api.") +} diff --git a/cmd/cli/cmd/root.go b/cmd/cli/cmd/root.go index b931447..becf611 100644 --- a/cmd/cli/cmd/root.go +++ b/cmd/cli/cmd/root.go @@ -120,7 +120,7 @@ func resetCommandOutput( } func init() { - rootCmd.PersistentFlags().StringVar(&host, "host", "", "The base URL of your Nullify API instance (e.g., acme.nullify.ai)") + rootCmd.PersistentFlags().StringVar(&host, "host", "", "Your Nullify instance (e.g., acme.nullify.ai), or nullify.ai to sign in through the Nullify router") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") rootCmd.PersistentFlags().StringVarP(&outputFmt, "output", "o", "json", "Output format (json, table, yaml, sarif)") @@ -192,7 +192,7 @@ func resolveHostE(ctx context.Context) (string, error) { if host != "" { sanitized, err := lib.SanitizeNullifyHost(host) if err != nil { - return "", withExitCode(1, fmt.Errorf("invalid host %q, must be in the format .nullify.ai", host)) + return "", withExitCode(1, fmt.Errorf("invalid host %q, must be nullify.ai or .nullify.ai", host)) } return sanitized, nil } @@ -214,19 +214,19 @@ func resolveHostE(ctx context.Context) (string, error) { logger.L(ctx).Warn("config file host is invalid, ignoring", logger.String("host", cfg.Host), logger.Err(err)) } - return "", authError("no host configured. Run 'nullify init' to set up, or 'nullify auth login --host .nullify.ai' to configure.") + return "", authError("no host configured. Run 'nullify init' to set up, or 'nullify auth login' to sign in.") } func getNullifyClientE(ctx context.Context) (*client.NullifyClient, error) { - nullifyHost, err := resolveHostE(ctx) + authCtx, err := resolveCommandHosts(ctx) if err != nil { return nil, err } - token, err := lib.GetNullifyToken(ctx, nullifyHost, nullifyToken, githubToken) + authCtx.Token, err = lib.GetNullifyToken(ctx, authCtx.AuthHost, nullifyToken, githubToken) if err != nil { return nil, authError("failed to get token. Run 'nullify auth login' to authenticate: %w", err) } - return client.NewNullifyClient(nullifyHost, token), nil + return authCtx.Client(), nil } diff --git a/cmd/cli/cmd/runtime_auth.go b/cmd/cli/cmd/runtime_auth.go index 33d2d0b..46be257 100644 --- a/cmd/cli/cmd/runtime_auth.go +++ b/cmd/cli/cmd/runtime_auth.go @@ -3,6 +3,7 @@ package cmd import ( "context" "errors" + "maps" "os" "github.com/nullify-platform/cli/internal/api" @@ -16,43 +17,90 @@ func stdinIsTTY() bool { return terminal.IsInteractive(os.Stdin) } +// commandAuthContext separates the host that issues tokens from the host that serves data. type commandAuthContext struct { - Host string + AuthHost string + DataHost string Token string QueryParams map[string]string } -// Client builds a NullifyClient for the resolved host and token. +// Client builds a NullifyClient for the data host and token. func (c *commandAuthContext) Client() *client.NullifyClient { - return client.NewNullifyClient(c.Host, c.Token) + return client.NewNullifyClient(c.DataHost, c.Token) } func (c *commandAuthContext) APIClient() *api.Client { - return api.NewClient(c.Host, c.Token, c.QueryParams) + return api.NewClient(c.DataHost, c.Token, c.QueryParams) } -func resolveCommandAuth(ctx context.Context) (*commandAuthContext, error) { +// resolveCommandHosts sets the auth and data hosts for the command host; only the router host has different ones. +func resolveCommandHosts(ctx context.Context) (*commandAuthContext, error) { commandHost, err := resolveHostE(ctx) if err != nil { return nil, err } + return commandHostsFor(commandHost) +} + +func commandHostsFor(commandHost string) (*commandAuthContext, error) { + if !auth.IsRouterHost(commandHost) { + return &commandAuthContext{AuthHost: commandHost, DataHost: commandHost}, nil + } + + dataHost, err := auth.DataHost(auth.RouterHost) + if errors.Is(err, auth.ErrNoDataHost) && explicitTokenSet() { + return nil, authError("nullify.ai serves sign-in only - with --nullify-token or NULLIFY_TOKEN, set --host or NULLIFY_HOST to .nullify.ai") + } + if err != nil { + return nil, authError("%w", err) + } + return &commandAuthContext{AuthHost: auth.RouterHost, DataHost: dataHost}, nil +} - token, err := lib.GetNullifyToken(ctx, commandHost, nullifyToken, githubToken) +func explicitTokenSet() bool { + if nullifyToken != "" || os.Getenv("NULLIFY_TOKEN") != "" { + return true + } + return os.Getenv("GITHUB_ACTIONS") == "true" && githubToken != "" && os.Getenv("GITHUB_REPOSITORY") != "" +} + +// routerSignInHint returns a hint when tenantHost has no credentials but is the data host of the router sign-in. +func routerSignInHint(tenantHost string) string { + creds, err := auth.LoadCredentials() + if err != nil { + return "" + } + if _, ok := creds[auth.CredentialKey(tenantHost)]; ok { + return "" + } + if creds[auth.RouterHost].DataHost != tenantHost { + return "" + } + return " (signed in through nullify.ai - use --host nullify.ai or 'nullify auth switch --host nullify.ai')" +} + +func resolveCommandAuth(ctx context.Context) (*commandAuthContext, error) { + authCtx, err := resolveCommandHosts(ctx) + if err != nil { + return nil, err + } + + authCtx.Token, err = lib.GetNullifyToken(ctx, authCtx.AuthHost, nullifyToken, githubToken) if err != nil { if errors.Is(err, lib.ErrNoToken) { return nil, authError("not authenticated. Run 'nullify auth login' first") } + if !auth.IsRouterHost(authCtx.AuthHost) { + return nil, authError("%w%s", err, routerSignInHint(authCtx.AuthHost)) + } return nil, authError("%w", err) } queryParams := map[string]string{} creds, err := auth.LoadCredentials() if err == nil { - if hostCreds, ok := creds[auth.CredentialKey(commandHost)]; ok && hostCreds.QueryParameters != nil { - for key, value := range hostCreds.QueryParameters { - queryParams[key] = value - } - } + maps.Copy(queryParams, creds[auth.CredentialKey(authCtx.AuthHost)].QueryParameters) } // NULLIFY_GITHUB_OWNER_ID env var provides the githubOwnerId query param @@ -63,9 +111,6 @@ func resolveCommandAuth(ctx context.Context) (*commandAuthContext, error) { } } - return &commandAuthContext{ - Host: commandHost, - Token: token, - QueryParams: queryParams, - }, nil + authCtx.QueryParams = queryParams + return authCtx, nil } diff --git a/cmd/cli/cmd/runtime_auth_test.go b/cmd/cli/cmd/runtime_auth_test.go index 3ff0ab2..bfd1378 100644 --- a/cmd/cli/cmd/runtime_auth_test.go +++ b/cmd/cli/cmd/runtime_auth_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/nullify-platform/cli/internal/auth" + "github.com/nullify-platform/cli/internal/client" "github.com/stretchr/testify/require" ) @@ -28,7 +29,8 @@ func TestResolveCommandAuthUsesEnvTokenWithoutStoredCredentials(t *testing.T) { authCtx, err := resolveCommandAuth(context.Background()) require.NoError(t, err) - require.Equal(t, "acme.nullify.ai", authCtx.Host) + require.Equal(t, "acme.nullify.ai", authCtx.AuthHost) + require.Equal(t, "acme.nullify.ai", authCtx.DataHost) require.Equal(t, "env-token", authCtx.Token) require.Empty(t, authCtx.QueryParams) } @@ -70,3 +72,229 @@ func TestResolveCommandAuthClonesStoredQueryParams(t *testing.T) { require.NoError(t, err) require.Equal(t, "123", creds[auth.CredentialKey("acme.nullify.ai")].QueryParameters["githubOwnerId"]) } + +func resetCommandFlags(t *testing.T) { + t.Helper() + originalHost := host + originalNullifyToken := nullifyToken + originalGithubToken := githubToken + host = "" + nullifyToken = "" + githubToken = "" + t.Cleanup(func() { + host = originalHost + nullifyToken = originalNullifyToken + githubToken = originalGithubToken + }) +} + +func saveRouterCredentials(t *testing.T, dataHost string) { + t.Helper() + require.NoError(t, auth.SaveHostCredentials(auth.RouterHost, auth.HostCredentials{ + AccessToken: "router-token", + RefreshToken: "router-refresh", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + DataHost: dataHost, + Installation: "acme", + QueryParameters: map[string]string{"githubOwnerId": "7"}, + })) +} + +func TestResolveCommandAuthHosts(t *testing.T) { + tests := []struct { + name string + configHost string + envHost string + flagHost string + envToken string + routerCreds bool + routerData string + tenantCreds bool + wantAuthHost string + wantDataHost string + wantToken string + wantParams map[string]string + wantErr string + githubCI bool + noHint bool + }{ + { + name: "router config uses the selected installation for data", + configHost: "nullify.ai", + routerCreds: true, + routerData: "acme.nullify.ai", + wantAuthHost: "nullify.ai", + wantDataHost: "acme.nullify.ai", + wantToken: "router-token", + wantParams: map[string]string{"githubOwnerId": "7"}, + }, + { + name: "router alias flag", + flagHost: "https://app.nullify.ai", + routerCreds: true, + routerData: "acme.nullify.ai", + wantAuthHost: "nullify.ai", + wantDataHost: "acme.nullify.ai", + wantToken: "router-token", + wantParams: map[string]string{"githubOwnerId": "7"}, + }, + { + name: "router without a selected installation fails", + configHost: "nullify.ai", + routerCreds: true, + routerData: "", + wantErr: "no installation selected", + }, + { + name: "router with an env token and no sign-in points CI at the instance", + configHost: "nullify.ai", + envToken: "env-token", + wantErr: "set --host or NULLIFY_HOST to .nullify.ai", + }, + { + name: "router with a GitHub Actions exchange and no sign-in points CI at the instance", + configHost: "nullify.ai", + githubCI: true, + wantErr: "set --host or NULLIFY_HOST to .nullify.ai", + }, + { + name: "tenant host ignores the router selection but hints at it", + envHost: "acme.nullify.ai", + routerCreds: true, + routerData: "acme.nullify.ai", + wantErr: "not authenticated for acme.nullify.ai - run 'nullify auth login --host acme.nullify.ai' (signed in through nullify.ai - use --host nullify.ai or 'nullify auth switch --host nullify.ai')", + }, + { + name: "tenant host without a matching router selection has no hint", + envHost: "beta.nullify.ai", + routerCreds: true, + routerData: "acme.nullify.ai", + wantErr: "not authenticated for beta.nullify.ai - run 'nullify auth login --host beta.nullify.ai'", + noHint: true, + }, + { + name: "tenant env token ignores router query parameters", + envHost: "acme.nullify.ai", + envToken: "env-token", + routerCreds: true, + routerData: "acme.nullify.ai", + wantAuthHost: "acme.nullify.ai", + wantDataHost: "acme.nullify.ai", + wantToken: "env-token", + wantParams: map[string]string{}, + }, + { + name: "tenant host uses its own credentials", + envHost: "acme.nullify.ai", + routerCreds: true, + routerData: "acme.nullify.ai", + tenantCreds: true, + wantAuthHost: "acme.nullify.ai", + wantDataHost: "acme.nullify.ai", + wantToken: "tenant-token", + wantParams: map[string]string{"githubOwnerId": "1"}, + }, + { + name: "other tenant stays on its own host", + envHost: "beta.nullify.ai", + envToken: "env-token", + routerCreds: true, + routerData: "acme.nullify.ai", + wantAuthHost: "beta.nullify.ai", + wantDataHost: "beta.nullify.ai", + wantToken: "env-token", + wantParams: map[string]string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("NULLIFY_HOST", tt.envHost) + t.Setenv("NULLIFY_TOKEN", tt.envToken) + t.Setenv("NULLIFY_GITHUB_OWNER_ID", "") + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("GITHUB_REPOSITORY", "") + resetCommandFlags(t) + host = tt.flagHost + if tt.githubCI { + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("GITHUB_REPOSITORY", "acme/repo") + githubToken = "gh-token" + } + + if tt.configHost != "" { + require.NoError(t, auth.SaveConfig(&auth.Config{Host: tt.configHost})) + } + if tt.routerCreds { + saveRouterCredentials(t, tt.routerData) + } + if tt.tenantCreds { + require.NoError(t, auth.SaveHostCredentials("acme.nullify.ai", auth.HostCredentials{ + AccessToken: "tenant-token", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + QueryParameters: map[string]string{"githubOwnerId": "1"}, + })) + } + + authCtx, err := resolveCommandAuth(context.Background()) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + if tt.noHint { + require.NotContains(t, err.Error(), "signed in through nullify.ai") + } + return + } + require.NoError(t, err) + require.Equal(t, tt.wantAuthHost, authCtx.AuthHost) + require.Equal(t, tt.wantDataHost, authCtx.DataHost) + require.Equal(t, tt.wantToken, authCtx.Token) + require.Equal(t, tt.wantParams, authCtx.QueryParams) + require.Equal(t, "https://api."+tt.wantDataHost, authCtx.APIClient().BaseURL) + require.Equal(t, "https://api."+tt.wantDataHost, authCtx.Client().BaseURL) + + nullifyClient, err := getNullifyClientE(context.Background()) + require.NoError(t, err) + require.Equal(t, "https://api."+tt.wantDataHost, nullifyClient.BaseURL) + }) + } +} + +func TestDashboardURL(t *testing.T) { + tests := map[string]string{ + "nullify.ai": "https://app.nullify.ai", + "acme.nullify.ai": "https://acme.nullify.ai", + "api.acme.nullify.ai": "https://acme.nullify.ai", + } + for commandHost, want := range tests { + require.Equal(t, want, dashboardURL(commandHost), commandHost) + } +} + +func TestMCPClientUsesAuthHostForTokensAndDataHostForRequests(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("NULLIFY_TOKEN", "") + t.Setenv("GITHUB_ACTIONS", "") + resetCommandFlags(t) + require.NoError(t, auth.SaveHostCredentials(auth.RouterHost, auth.HostCredentials{ + AccessToken: "router-token", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + DataHost: "acme.nullify.ai", + })) + authCtx := &commandAuthContext{AuthHost: auth.RouterHost, DataHost: "acme.nullify.ai"} + + apiClient, err := newMCPAPIClient(context.Background(), authCtx, map[string]string{"repository": "r"}) + require.NoError(t, err) + require.Equal(t, "https://api.acme.nullify.ai", apiClient.BaseURL) + require.Equal(t, map[string]string{"repository": "r"}, apiClient.DefaultParams) + + tokenProvider, refreshProvider := mcpTokenProviders(context.Background(), authCtx.AuthHost) + token, err := tokenProvider() + require.NoError(t, err) + require.Equal(t, "router-token", token) + _, err = refreshProvider() + require.ErrorIs(t, err, client.ErrTokenNotRefreshable, "refresh must look up the router credentials") + + dataTokenProvider, _ := mcpTokenProviders(context.Background(), authCtx.DataHost) + _, err = dataTokenProvider() + require.ErrorContains(t, err, "not authenticated for acme.nullify.ai") +} diff --git a/cmd/cli/cmd/whoami.go b/cmd/cli/cmd/whoami.go index fc49cd3..e50ccc6 100644 --- a/cmd/cli/cmd/whoami.go +++ b/cmd/cli/cmd/whoami.go @@ -24,15 +24,27 @@ var whoamiCmd = &cobra.Command{ "host": whoamiHost, } + hosts, hostsErr := commandHostsFor(whoamiHost) + if hostsErr == nil && hosts.AuthHost != hosts.DataHost { + info["auth_host"] = hosts.AuthHost + info["data_host"] = hosts.DataHost + } + creds, err := auth.LoadCredentials() - if err != nil { + if hostsErr != nil { + info["authenticated"] = false + info["error"] = hostsErr.Error() + } else if err != nil { info["authenticated"] = false info["error"] = "no credentials found" - } else if hostCreds, ok := creds[auth.CredentialKey(whoamiHost)]; !ok { + } else if hostCreds, ok := creds[auth.CredentialKey(hosts.AuthHost)]; !ok { info["authenticated"] = false - info["error"] = fmt.Sprintf("no credentials for host %s", whoamiHost) + info["error"] = fmt.Sprintf("no credentials for host %s", hosts.AuthHost) } else { info["authenticated"] = true + if hostCreds.Installation != "" { + info["installation"] = hostCreds.Installation + } if hostCreds.QueryParameters != nil { info["query_parameters"] = hostCreds.QueryParameters } diff --git a/install.sh b/install.sh index 6f3ef5d..f902bbf 100755 --- a/install.sh +++ b/install.sh @@ -161,7 +161,7 @@ main() { if [ -n "$NULLIFY_HOST" ]; then echo "Run 'nullify auth login' to authenticate." else - echo "Run 'nullify auth login --host .nullify.ai' to get started." + echo "Run 'nullify auth login' to sign in through nullify.ai, or 'nullify auth login --host .nullify.ai' for your own instance." fi } diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index 489a6ff..0483147 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -13,6 +13,8 @@ type HostCredentials struct { RefreshToken string `json:"refresh_token"` ExpiresAt int64 `json:"expires_at"` QueryParameters map[string]string `json:"query_parameters,omitempty"` + DataHost string `json:"data_host,omitempty"` + Installation string `json:"installation,omitempty"` } type Credentials map[string]HostCredentials @@ -128,6 +130,21 @@ func SaveHostCredentials(host string, hostCreds HostCredentials) error { return SaveCredentials(creds) } +func updateHostCredentials(host string, update func(HostCredentials) HostCredentials) error { + creds, err := LoadCredentials() + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("cannot update credentials: %w", err) + } + creds = make(Credentials) + } + + key := CredentialKey(host) + creds[key] = update(creds[key]) + + return SaveCredentials(creds) +} + func DeleteHostCredentials(host string) error { creds, err := LoadCredentials() if err != nil { diff --git a/internal/auth/installations.go b/internal/auth/installations.go new file mode 100644 index 0000000..317fd1c --- /dev/null +++ b/internal/auth/installations.go @@ -0,0 +1,151 @@ +package auth + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "maps" + "strconv" + "strings" + + "github.com/nullify-platform/cli/internal/api" + "github.com/nullify-platform/cli/internal/api/models" + "github.com/nullify-platform/cli/internal/logger" +) + +var errNoUsableInstallation = errors.New("no installation is ready for CLI access through nullify.ai - run 'nullify auth login --host .nullify.ai' with your Nullify instance") + +// Installation is a tenant installation that the router can route the user to. +type Installation struct { + Name string + DataHost string + QueryParameters map[string]string +} + +// InstallationPicker selects one installation by name, by prompt, or automatically when only one exists. +type InstallationPicker struct { + Name string + Interactive bool + In io.Reader + Out io.Writer +} + +func fetchInstallations(ctx context.Context, token string) ([]Installation, error) { + client := api.NewClient(RouterHost, token, nil, api.WithHTTPClient(httpClient)) + client.BaseURL = authBaseURL(RouterHost) + + out, err := client.ListAdminInstallations(ctx, api.ListAdminInstallationsInput{}) + if err != nil { + return nil, fmt.Errorf("list installations: %w", err) + } + return usableInstallations(ctx, out.Installations), nil +} + +func usableInstallations(ctx context.Context, outputs []models.EndpointsAdminInstallationOutput) []Installation { + installs := make([]Installation, 0, len(outputs)) + for _, out := range outputs { + if out.NullifyBaseURL == nil { + logger.L(ctx).Debug("skipping installation without a base url", logger.String("installation", out.Name)) + continue + } + if len(out.QueryParameters) == 0 { + logger.L(ctx).Debug("skipping installation without query parameters", logger.String("installation", out.Name)) + continue + } + + dataHost, err := dataHostFromBaseURL(*out.NullifyBaseURL) + if err != nil { + logger.L(ctx).Debug("skipping installation with invalid base url", logger.String("installation", out.Name), logger.Err(err)) + continue + } + + installs = append(installs, Installation{ + Name: out.Name, + DataHost: dataHost, + QueryParameters: maps.Clone(out.QueryParameters), + }) + } + return installs +} + +// Pick returns the selected installation from installs. +func (p InstallationPicker) Pick(installs []Installation) (Installation, error) { + if len(installs) == 0 { + return Installation{}, errNoUsableInstallation + } + + if p.Name != "" { + return findInstallation(installs, p.Name) + } + + if len(installs) == 1 { + return installs[0], nil + } + + if !p.Interactive { + return Installation{}, fmt.Errorf("multiple installations are available (%s) - pass --installation ", installationNames(installs)) + } + return p.prompt(installs) +} + +func findInstallation(installs []Installation, name string) (Installation, error) { + var matches []Installation + for _, install := range installs { + if install.Name == name { + matches = append(matches, install) + } + } + + switch len(matches) { + case 0: + return Installation{}, fmt.Errorf("installation %q not found - available: %s", name, installationNames(installs)) + case 1: + return matches[0], nil + default: + return Installation{}, fmt.Errorf("installation name %q matches %d installations - run 'nullify auth login --host .nullify.ai'", name, len(matches)) + } +} + +func (p InstallationPicker) prompt(installs []Installation) (Installation, error) { + if err := ensureUniqueNames(installs); err != nil { + return Installation{}, err + } + + fmt.Fprintln(p.Out, "Select an installation:") + for i, install := range installs { + fmt.Fprintf(p.Out, " %d) %s (%s)\n", i+1, install.Name, install.DataHost) + } + fmt.Fprintf(p.Out, "Enter a number [1-%d]: ", len(installs)) + + line, err := bufio.NewReader(p.In).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return Installation{}, fmt.Errorf("read selection: %w", err) + } + + choice, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || choice < 1 || choice > len(installs) { + return Installation{}, fmt.Errorf("invalid selection %q - enter a number from 1 to %d", strings.TrimSpace(line), len(installs)) + } + return installs[choice-1], nil +} + +func ensureUniqueNames(installs []Installation) error { + seen := make(map[string]bool, len(installs)) + for _, install := range installs { + if seen[install.Name] { + return fmt.Errorf("installation name %q is used by more than one installation - run 'nullify auth login --host .nullify.ai'", install.Name) + } + seen[install.Name] = true + } + return nil +} + +func installationNames(installs []Installation) string { + names := make([]string, 0, len(installs)) + for _, install := range installs { + names = append(names, install.Name) + } + return strings.Join(names, ", ") +} diff --git a/internal/auth/installations_test.go b/internal/auth/installations_test.go new file mode 100644 index 0000000..79713bc --- /dev/null +++ b/internal/auth/installations_test.go @@ -0,0 +1,116 @@ +package auth + +import ( + "bytes" + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/nullify-platform/cli/internal/api/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUsableInstallations(t *testing.T) { + params := map[string]string{"githubOwnerId": "1"} + outputs := []models.EndpointsAdminInstallationOutput{ + {Name: "ready", NullifyBaseURL: new("https://api.acme.nullify.ai"), QueryParameters: params}, + {Name: "pending", QueryParameters: params}, + {Name: "no-params", NullifyBaseURL: new("https://api.beta.nullify.ai")}, + {Name: "bad-url", NullifyBaseURL: new("https://api.acme.evil.com"), QueryParameters: params}, + {Name: "router-url", NullifyBaseURL: new("https://api.nullify.ai"), QueryParameters: params}, + } + + got := usableInstallations(context.Background(), outputs) + + require.Equal(t, []Installation{ + {Name: "ready", DataHost: "acme.nullify.ai", QueryParameters: params}, + }, got) + + got[0].QueryParameters["githubOwnerId"] = "2" + require.Equal(t, "1", params["githubOwnerId"], "installation must not share the response map") +} + +func TestInstallationPickerPick(t *testing.T) { + acme := Installation{Name: "acme", DataHost: "acme.nullify.ai"} + beta := Installation{Name: "beta", DataHost: "beta.nullify.ai"} + acmeGitLab := Installation{Name: "acme", DataHost: "acme-gl.nullify.ai"} + + tests := []struct { + name string + picker InstallationPicker + installs []Installation + want Installation + wantErr string + wantErrIs error + wantPrompts bool + }{ + {name: "none", installs: nil, wantErrIs: errNoUsableInstallation}, + {name: "single selects automatically", installs: []Installation{acme}, want: acme}, + {name: "single still honours name", picker: InstallationPicker{Name: "beta"}, installs: []Installation{acme}, wantErr: `installation "beta" not found - available: acme`}, + {name: "by name", picker: InstallationPicker{Name: "beta"}, installs: []Installation{acme, beta}, want: beta}, + {name: "duplicate name", picker: InstallationPicker{Name: "acme"}, installs: []Installation{acme, acmeGitLab}, wantErr: "matches 2 installations"}, + {name: "many without terminal", installs: []Installation{acme, beta}, wantErr: "multiple installations are available (acme, beta) - pass --installation "}, + {name: "prompt", picker: InstallationPicker{Interactive: true, In: strings.NewReader("2\n")}, installs: []Installation{acme, beta}, want: beta, wantPrompts: true}, + {name: "prompt without newline", picker: InstallationPicker{Interactive: true, In: strings.NewReader("1")}, installs: []Installation{acme, beta}, want: acme, wantPrompts: true}, + {name: "prompt out of range", picker: InstallationPicker{Interactive: true, In: strings.NewReader("3\n")}, installs: []Installation{acme, beta}, wantErr: "invalid selection", wantPrompts: true}, + {name: "prompt not a number", picker: InstallationPicker{Interactive: true, In: strings.NewReader("acme\n")}, installs: []Installation{acme, beta}, wantErr: "invalid selection", wantPrompts: true}, + {name: "prompt with duplicate names", picker: InstallationPicker{Interactive: true, In: strings.NewReader("1\n")}, installs: []Installation{acme, acmeGitLab}, wantErr: "used by more than one installation"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var out bytes.Buffer + picker := tt.picker + picker.Out = &out + + got, err := picker.Pick(tt.installs) + + switch { + case tt.wantErrIs != nil: + require.ErrorIs(t, err, tt.wantErrIs) + case tt.wantErr != "": + require.ErrorContains(t, err, tt.wantErr) + default: + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + + if tt.wantPrompts { + require.Contains(t, out.String(), "1) acme (acme.nullify.ai)") + require.Contains(t, out.String(), "2) beta (beta.nullify.ai)") + } else { + require.Empty(t, out.String()) + } + }) + } +} + +func TestFetchInstallationsSendsBearerToRouter(t *testing.T) { + newAuthServer(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/admin/installations", r.URL.Path) + assert.Equal(t, "Bearer router-token", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"installations":[{"name":"acme","avatarUrl":"","provider":{},` + + `"nullifyBaseUrl":"https://api.acme.nullify.ai","queryParameters":{"githubOwnerId":"7"}}]}`)) + }) + + authHosts := recordAuthHosts(t) + + got, err := fetchInstallations(context.Background(), "router-token") + require.NoError(t, err) + requireOnlyRouterHost(t, *authHosts, 1) + require.Equal(t, []Installation{ + {Name: "acme", DataHost: "acme.nullify.ai", QueryParameters: map[string]string{"githubOwnerId": "7"}}, + }, got) +} + +func TestFetchInstallationsReportsServerError(t *testing.T) { + newAuthServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + }) + + _, err := fetchInstallations(context.Background(), "router-token") + require.Error(t, err) + require.False(t, errors.Is(err, errNoUsableInstallation)) +} diff --git a/internal/auth/login.go b/internal/auth/login.go index 1d6cfb0..ae9490e 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -8,6 +8,7 @@ import ( "io" "net" "net/http" + "os" "os/exec" "runtime" "strings" @@ -22,6 +23,8 @@ var httpClient = &http.Client{Timeout: 30 * time.Second} // tests can point the auth calls at an httptest server. var authBaseURL = func(host string) string { return "https://" + apiHost(host) } +var openBrowser = OpenBrowser + type cliSessionResponse struct { SessionID string `json:"session_id"` AuthURL string `json:"auth_url"` @@ -35,7 +38,10 @@ type cliTokenResponse struct { QueryParameters map[string]string `json:"query_parameters,omitempty"` } -func Login(ctx context.Context, host string) error { +// Login signs in to host in the browser. For the router host it also selects the installation that serves data. +func Login(ctx context.Context, host string, picker InstallationPicker) error { + host = normalizeHost(host) + listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return fmt.Errorf("failed to start local server: %w", err) @@ -64,7 +70,7 @@ func Login(ctx context.Context, host string) error { fmt.Printf("\nOpening browser to authenticate...\n") fmt.Printf("If the browser doesn't open, visit:\n %s\n\n", sessionResp.AuthURL) - if err := OpenBrowser(sessionResp.AuthURL); err != nil { + if err := openBrowser(sessionResp.AuthURL); err != nil { logger.L(ctx).Debug("could not open browser automatically", logger.Err(err)) fmt.Println("(Could not open browser automatically. Please open the URL above manually.)") } @@ -102,14 +108,23 @@ waitLoop: return fmt.Errorf("authentication failed: %s", tokenResp.Error) } - expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Unix() - - err = SaveHostCredentials(host, HostCredentials{ + hostCreds := HostCredentials{ AccessToken: tokenResp.AccessToken, RefreshToken: tokenResp.RefreshToken, - ExpiresAt: expiresAt, + ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Unix(), QueryParameters: tokenResp.QueryParameters, - }) + } + + if host == RouterHost { + install, err := selectInstallation(ctx, tokenResp.AccessToken, picker) + if err != nil { + return err + } + hostCreds = withInstallation(hostCreds, install) + fmt.Printf("Using installation %s (%s)\n", install.Name, install.DataHost) + } + + err = SaveHostCredentials(host, hostCreds) if err != nil { return fmt.Errorf("failed to save credentials: %w", err) } @@ -123,11 +138,82 @@ waitLoop: return nil } +func selectInstallation(ctx context.Context, token string, picker InstallationPicker) (Installation, error) { + installs, err := fetchInstallations(ctx, token) + if err != nil { + return Installation{}, err + } + return picker.Pick(installs) +} + +func withInstallation(hostCreds HostCredentials, install Installation) HostCredentials { + hostCreds.DataHost = install.DataHost + hostCreds.Installation = install.Name + hostCreds.QueryParameters = install.QueryParameters + return hostCreds +} + +// SwitchInstallation selects the named installation for the stored router credentials and makes the router the default host. +func SwitchInstallation(ctx context.Context, name string) (Installation, error) { + token, err := GetValidToken(ctx, RouterHost) + if err != nil { + return Installation{}, err + } + + install, err := selectInstallation(ctx, token, InstallationPicker{Name: name}) + if err != nil { + return Installation{}, err + } + + err = updateHostCredentials(RouterHost, func(hostCreds HostCredentials) HostCredentials { + return withInstallation(hostCreds, install) + }) + if err != nil { + return Installation{}, fmt.Errorf("failed to save credentials: %w", err) + } + + if err := SaveConfig(&Config{Host: RouterHost}); err != nil { + return Installation{}, fmt.Errorf("failed to save config: %w", err) + } + return install, nil +} + +// DataHost returns the host that serves data for the credentials stored under authHost. +func DataHost(authHost string) (string, error) { + if !IsRouterHost(authHost) { + return authHost, nil + } + + creds, err := LoadCredentials() + if err != nil { + if os.IsNotExist(err) { + return "", ErrNoDataHost + } + return "", fmt.Errorf("load credentials: %w", err) + } + dataHost := creds[RouterHost].DataHost + if dataHost == "" { + return "", ErrNoDataHost + } + return dataHost, nil +} + +// CheckSession returns an error unless host has a valid token and, for the router, a selected installation. +func CheckSession(ctx context.Context, host string) error { + if _, err := GetValidToken(ctx, host); err != nil { + return err + } + _, err := DataHost(host) + return err +} + func Logout(host string) error { return DeleteHostCredentials(host) } func GetValidToken(ctx context.Context, host string) (string, error) { + host = normalizeHost(host) + creds, err := LoadCredentials() if err != nil { return "", fmt.Errorf("not authenticated - run 'nullify auth login'") @@ -164,6 +250,8 @@ var ErrNotRefreshable = errors.New("no refresh token stored") // the server rejects while the CLI still believes it is valid -- revocation, a // killed session, or a local clock running behind the server's. func ForceRefreshToken(ctx context.Context, host string) (string, error) { + host = normalizeHost(host) + creds, err := LoadCredentials() if err != nil { return "", fmt.Errorf("not authenticated - run 'nullify auth login'") @@ -299,11 +387,14 @@ func refreshToken(ctx context.Context, host string, refreshTok string) (string, expiresAt := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second).Unix() - err = SaveHostCredentials(host, HostCredentials{ - AccessToken: result.AccessToken, - RefreshToken: refreshTok, - ExpiresAt: expiresAt, - QueryParameters: result.QueryParameters, + err = updateHostCredentials(host, func(hostCreds HostCredentials) HostCredentials { + hostCreds.AccessToken = result.AccessToken + hostCreds.RefreshToken = refreshTok + hostCreds.ExpiresAt = expiresAt + if hostCreds.DataHost == "" { + hostCreds.QueryParameters = result.QueryParameters + } + return hostCreds }) if err != nil { logger.L(ctx).Warn("failed to save refreshed credentials", logger.Err(err)) diff --git a/internal/auth/login_router_test.go b/internal/auth/login_router_test.go new file mode 100644 index 0000000..65af2c2 --- /dev/null +++ b/internal/auth/login_router_test.go @@ -0,0 +1,355 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testRedemptionSecret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +type fakeLoginServer struct { + installations string + installationsCalls int +} + +func (f *fakeLoginServer) handle(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/auth/cli/session": + var body struct { + Port int `json:"port"` + } + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + authURL := fmt.Sprintf("http://127.0.0.1:%d/callback?session_id=s1&redemption_secret=%s", body.Port, testRedemptionSecret) + _ = json.NewEncoder(w).Encode(cliSessionResponse{SessionID: "s1", AuthURL: authURL}) + case "/auth/cli/token": + _ = json.NewEncoder(w).Encode(cliTokenResponse{ + AccessToken: "access", + RefreshToken: "refresh", + ExpiresIn: 3600, + QueryParameters: map[string]string{"fromToken": "1"}, + }) + case "/admin/installations": + f.installationsCalls++ + assert.Equal(t, "Bearer access", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(f.installations)) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + } +} + +// recordAuthHosts records the host argument of every authBaseURL call. +func recordAuthHosts(t *testing.T) *[]string { + t.Helper() + var mu sync.Mutex + hosts := []string{} + old := authBaseURL + authBaseURL = func(host string) string { + mu.Lock() + defer mu.Unlock() + hosts = append(hosts, host) + return old(host) + } + t.Cleanup(func() { authBaseURL = old }) + return &hosts +} + +func requireOnlyRouterHost(t *testing.T, hosts []string, wantCalls int) { + t.Helper() + require.Len(t, hosts, wantCalls) + for _, host := range hosts { + require.Equal(t, RouterHost, host) + } +} + +// stubBrowser completes the browser step by calling the local callback URL. +func stubBrowser(t *testing.T) { + t.Helper() + old := openBrowser + openBrowser = func(url string) error { + go func() { + resp, err := http.Get(url) + if err == nil { + resp.Body.Close() + } + }() + return nil + } + t.Cleanup(func() { openBrowser = old }) +} + +func installationJSON(name, baseURL string) string { + return fmt.Sprintf(`{"name":%q,"avatarUrl":"","provider":{},"nullifyBaseUrl":%q,"queryParameters":{"githubOwnerId":"%s-id"}}`, name, baseURL, name) +} + +func TestLoginRouterHost(t *testing.T) { + acme := installationJSON("acme", "https://api.acme.nullify.ai") + beta := installationJSON("beta", "https://api.beta.nullify.ai") + pending := `{"name":"pending","avatarUrl":"","provider":{},"queryParameters":{"githubOwnerId":"9"}}` + + tests := []struct { + name string + host string + picker InstallationPicker + installations []string + wantErr string + wantCreds HostCredentials + }{ + { + name: "single installation", + host: "nullify.ai", + installations: []string{acme, pending}, + wantCreds: HostCredentials{ + AccessToken: "access", + RefreshToken: "refresh", + DataHost: "acme.nullify.ai", + Installation: "acme", + QueryParameters: map[string]string{"githubOwnerId": "acme-id"}, + }, + }, + { + name: "api alias from the wizard", + host: "api.nullify.ai", + picker: InstallationPicker{Name: "beta"}, + installations: []string{acme, beta}, + wantCreds: HostCredentials{ + AccessToken: "access", + RefreshToken: "refresh", + DataHost: "beta.nullify.ai", + Installation: "beta", + QueryParameters: map[string]string{"githubOwnerId": "beta-id"}, + }, + }, + { + name: "no usable installation", + host: "nullify.ai", + installations: []string{pending}, + wantErr: "nullify auth login --host .nullify.ai", + }, + { + name: "several installations without a terminal", + host: "nullify.ai", + installations: []string{acme, beta}, + wantErr: "pass --installation ", + }, + { + name: "unknown installation name", + host: "app.nullify.ai", + picker: InstallationPicker{Name: "gamma"}, + installations: []string{acme, beta}, + wantErr: `installation "gamma" not found`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := &fakeLoginServer{installations: `{"installations":[` + strings.Join(tt.installations, ",") + `]}`} + newAuthServer(t, server.handle(t)) + authHosts := recordAuthHosts(t) + stubBrowser(t) + require.NoError(t, SaveConfig(&Config{Host: "previous.nullify.ai"})) + + err := Login(context.Background(), tt.host, tt.picker) + + require.Equal(t, 1, server.installationsCalls) + requireOnlyRouterHost(t, *authHosts, 3) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + _, credsErr := LoadCredentials() + require.True(t, os.IsNotExist(credsErr), "credentials must not be written on a failed selection") + cfg, cfgErr := LoadConfig() + require.NoError(t, cfgErr) + require.Equal(t, "previous.nullify.ai", cfg.Host) + return + } + require.NoError(t, err) + + creds, err := LoadCredentials() + require.NoError(t, err) + require.Len(t, creds, 1) + got := creds[RouterHost] + require.InDelta(t, time.Now().Add(time.Hour).Unix(), got.ExpiresAt, 5) + got.ExpiresAt = 0 + require.Equal(t, tt.wantCreds, got) + + cfg, err := LoadConfig() + require.NoError(t, err) + require.Equal(t, RouterHost, cfg.Host) + }) + } +} + +func TestLoginTenantHostSkipsInstallationDiscovery(t *testing.T) { + server := &fakeLoginServer{} + newAuthServer(t, server.handle(t)) + stubBrowser(t) + + require.NoError(t, Login(context.Background(), "acme.nullify.ai", InstallationPicker{})) + + require.Zero(t, server.installationsCalls) + creds, err := LoadCredentials() + require.NoError(t, err) + got := creds["acme.nullify.ai"] + require.Empty(t, got.DataHost) + require.Equal(t, map[string]string{"fromToken": "1"}, got.QueryParameters) + cfg, err := LoadConfig() + require.NoError(t, err) + require.Equal(t, "acme.nullify.ai", cfg.Host) +} + +func TestRefreshTokenKeepsRouterSelection(t *testing.T) { + newAuthServer(t, refreshHandler) + authHosts := recordAuthHosts(t) + require.NoError(t, SaveHostCredentials(RouterHost, HostCredentials{ + AccessToken: "old", + RefreshToken: "rt", + ExpiresAt: time.Now().Add(-time.Hour).Unix(), + DataHost: "acme.nullify.ai", + Installation: "acme", + QueryParameters: map[string]string{"githubOwnerId": "7"}, + })) + + token, err := GetValidToken(context.Background(), "api.nullify.ai") + require.NoError(t, err) + require.Equal(t, "new-id-token", token) + requireOnlyRouterHost(t, *authHosts, 1) + + creds, err := LoadCredentials() + require.NoError(t, err) + got := creds[RouterHost] + require.Equal(t, "new-id-token", got.AccessToken) + require.Equal(t, "rt", got.RefreshToken) + require.Equal(t, "acme.nullify.ai", got.DataHost) + require.Equal(t, "acme", got.Installation) + require.Equal(t, map[string]string{"githubOwnerId": "7"}, got.QueryParameters) +} + +func TestRefreshTokenTakesResponseQueryParametersWithoutDataHost(t *testing.T) { + newAuthServer(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"accessToken":"new","expiresIn":60}`)) + }) + require.NoError(t, SaveHostCredentials("acme.nullify.ai", HostCredentials{ + AccessToken: "old", + RefreshToken: "rt", + QueryParameters: map[string]string{"githubOwnerId": "7"}, + })) + + _, err := ForceRefreshToken(context.Background(), "acme.nullify.ai") + require.NoError(t, err) + + creds, err := LoadCredentials() + require.NoError(t, err) + require.Empty(t, creds["acme.nullify.ai"].QueryParameters) +} + +func TestRefreshTokenRereadsCredentialsBeforeWriting(t *testing.T) { + newAuthServer(t, func(w http.ResponseWriter, r *http.Request) { + assert.NoError(t, SaveHostCredentials("other.nullify.ai", HostCredentials{AccessToken: "other"})) + assert.NoError(t, updateHostCredentials(RouterHost, func(hostCreds HostCredentials) HostCredentials { + hostCreds.DataHost = "beta.nullify.ai" + hostCreds.Installation = "beta" + return hostCreds + })) + refreshHandler(w, r) + }) + require.NoError(t, SaveHostCredentials(RouterHost, HostCredentials{ + AccessToken: "old", + RefreshToken: "rt", + DataHost: "acme.nullify.ai", + Installation: "acme", + })) + + _, err := ForceRefreshToken(context.Background(), "app.nullify.ai") + require.NoError(t, err) + + creds, err := LoadCredentials() + require.NoError(t, err) + require.Equal(t, "other", creds["other.nullify.ai"].AccessToken) + require.Equal(t, "new-id-token", creds[RouterHost].AccessToken) + require.Equal(t, "beta.nullify.ai", creds[RouterHost].DataHost) + require.Equal(t, "beta", creds[RouterHost].Installation) +} + +func TestDataHost(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + got, err := DataHost("acme.nullify.ai") + require.NoError(t, err) + require.Equal(t, "acme.nullify.ai", got) + + _, err = DataHost(RouterHost) + require.ErrorIs(t, err, ErrNoDataHost) + + require.NoError(t, SaveHostCredentials(RouterHost, HostCredentials{AccessToken: "t"})) + _, err = DataHost("api.nullify.ai") + require.ErrorIs(t, err, ErrNoDataHost) + + require.NoError(t, SaveHostCredentials(RouterHost, HostCredentials{AccessToken: "t", DataHost: "acme.nullify.ai"})) + got, err = DataHost("api.nullify.ai") + require.NoError(t, err) + require.Equal(t, "acme.nullify.ai", got) + + path, err := credentialsPath() + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, []byte("not json"), 0600)) + _, err = DataHost(RouterHost) + require.Error(t, err) + require.NotErrorIs(t, err, ErrNoDataHost) +} + +func TestSwitchInstallation(t *testing.T) { + server := &fakeLoginServer{installations: `{"installations":[` + + installationJSON("acme", "https://api.acme.nullify.ai") + "," + + installationJSON("beta", "https://api.beta.nullify.ai") + `]}`} + newAuthServer(t, server.handle(t)) + authHosts := recordAuthHosts(t) + require.NoError(t, SaveHostCredentials(RouterHost, HostCredentials{ + AccessToken: "access", + RefreshToken: "refresh", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + DataHost: "acme.nullify.ai", + Installation: "acme", + QueryParameters: map[string]string{"githubOwnerId": "acme-id"}, + })) + require.NoError(t, SaveConfig(&Config{Host: "other.nullify.ai"})) + + _, err := SwitchInstallation(context.Background(), "gamma") + require.ErrorContains(t, err, `installation "gamma" not found`) + creds, err := LoadCredentials() + require.NoError(t, err) + require.Equal(t, "acme", creds[RouterHost].Installation) + + install, err := SwitchInstallation(context.Background(), "beta") + require.NoError(t, err) + requireOnlyRouterHost(t, *authHosts, 2) + require.Equal(t, "beta.nullify.ai", install.DataHost) + + creds, err = LoadCredentials() + require.NoError(t, err) + got := creds[RouterHost] + require.Equal(t, "access", got.AccessToken) + require.Equal(t, "beta.nullify.ai", got.DataHost) + require.Equal(t, "beta", got.Installation) + require.Equal(t, map[string]string{"githubOwnerId": "beta-id"}, got.QueryParameters) + cfg, err := LoadConfig() + require.NoError(t, err) + require.Equal(t, RouterHost, cfg.Host) +} + +func TestSwitchInstallationRequiresRouterLogin(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + _, err := SwitchInstallation(context.Background(), "acme") + require.ErrorContains(t, err, "not authenticated") +} diff --git a/internal/auth/router.go b/internal/auth/router.go new file mode 100644 index 0000000..acaf89e --- /dev/null +++ b/internal/auth/router.go @@ -0,0 +1,99 @@ +package auth + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +// RouterHost is the bare host of the Nullify router, which serves sign-in but not tenant data. +const RouterHost = "nullify.ai" + +// RouterAppURL is the dashboard URL for users who sign in through the router. +const RouterAppURL = "https://app.nullify.ai" + +var routerAliases = map[string]bool{ + "nullify.ai": true, + "api.nullify.ai": true, + "app.nullify.ai": true, +} + +// ErrNoDataHost reports that the router credentials have no selected installation. +var ErrNoDataHost = errors.New("no installation selected for nullify.ai - run 'nullify auth login' or 'nullify auth switch --installation '") + +func normalizeHost(host string) string { + if IsRouterHost(host) { + return RouterHost + } + return host +} + +// IsRouterHost reports whether host is a bare, "api." or "app." form of the router host. +func IsRouterHost(host string) bool { + h := strings.ToLower(strings.TrimSpace(host)) + if i := strings.Index(h, "://"); i >= 0 { + h = h[i+len("://"):] + } + if i := strings.IndexAny(h, "/?#"); i >= 0 { + h = h[:i] + } + return routerAliases[h] +} + +// dataHostFromBaseURL returns the bare tenant host of a plain https tenant API origin. +func dataHostFromBaseURL(baseURL string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("parse base URL %q: %w", baseURL, err) + } + if u.Scheme != "https" { + return "", fmt.Errorf("base URL %q is not https", baseURL) + } + if u.User != nil { + return "", fmt.Errorf("base URL %q must not contain userinfo", baseURL) + } + if u.Port() != "" { + return "", fmt.Errorf("base URL %q must not contain a port", baseURL) + } + if u.Path != "" && u.Path != "/" { + return "", fmt.Errorf("base URL %q must not contain a path", baseURL) + } + if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + return "", fmt.Errorf("base URL %q must not contain a query or fragment", baseURL) + } + + tenant, ok := tenantLabel(u.Hostname()) + if !ok { + return "", fmt.Errorf("base URL %q is not a Nullify tenant API host", baseURL) + } + return tenant + "." + RouterHost, nil +} + +// tenantLabel returns from a host of the form "api..nullify.ai". +func tenantLabel(hostname string) (string, bool) { + rest, ok := strings.CutPrefix(hostname, "api.") + if !ok { + return "", false + } + label, ok := strings.CutSuffix(rest, "."+RouterHost) + if !ok || !validTenantLabel(label) { + return "", false + } + return label, true +} + +func validTenantLabel(label string) bool { + if label == "" || len(label) > 63 || label == "api" || label == "app" { + return false + } + if label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, c := range label { + if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' { + return false + } + } + return true +} diff --git a/internal/auth/router_test.go b/internal/auth/router_test.go new file mode 100644 index 0000000..ff0ce1d --- /dev/null +++ b/internal/auth/router_test.go @@ -0,0 +1,78 @@ +package auth + +import "testing" + +func TestIsRouterHost(t *testing.T) { + tests := []struct { + host string + want bool + }{ + {"nullify.ai", true}, + {"api.nullify.ai", true}, + {"app.nullify.ai", true}, + {"https://app.nullify.ai/dashboard", true}, + {"HTTPS://API.NULLIFY.AI", true}, + {" nullify.ai ", true}, + {"acme.nullify.ai", false}, + {"api.acme.nullify.ai", false}, + {"app.acme.nullify.ai", false}, + {"nullify.ai.evil.com", false}, + {"evilnullify.ai", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + if got := IsRouterHost(tt.host); got != tt.want { + t.Errorf("IsRouterHost(%q) = %v, want %v", tt.host, got, tt.want) + } + }) + } +} + +func TestDataHostFromBaseURL(t *testing.T) { + tests := []struct { + name string + baseURL string + want string + wantErr bool + }{ + {name: "tenant api origin", baseURL: "https://api.acme.nullify.ai", want: "acme.nullify.ai"}, + {name: "trailing slash", baseURL: "https://api.acme.nullify.ai/", want: "acme.nullify.ai"}, + {name: "hyphen and digits", baseURL: "https://api.acme-2.nullify.ai", want: "acme-2.nullify.ai"}, + {name: "http", baseURL: "http://api.acme.nullify.ai", wantErr: true}, + {name: "userinfo", baseURL: "https://user:pass@api.acme.nullify.ai", wantErr: true}, + {name: "port", baseURL: "https://api.acme.nullify.ai:8443", wantErr: true}, + {name: "explicit https port", baseURL: "https://api.acme.nullify.ai:443", wantErr: true}, + {name: "path", baseURL: "https://api.acme.nullify.ai/v1", wantErr: true}, + {name: "query", baseURL: "https://api.acme.nullify.ai?x=1", wantErr: true}, + {name: "empty query", baseURL: "https://api.acme.nullify.ai?", wantErr: true}, + {name: "fragment", baseURL: "https://api.acme.nullify.ai#x", wantErr: true}, + {name: "uppercase", baseURL: "https://API.ACME.NULLIFY.AI", wantErr: true}, + {name: "trailing dot", baseURL: "https://api.acme.nullify.ai.", wantErr: true}, + {name: "router host", baseURL: "https://api.nullify.ai", wantErr: true}, + {name: "app host", baseURL: "https://api.app.nullify.ai", wantErr: true}, + {name: "no api prefix", baseURL: "https://acme.nullify.ai", wantErr: true}, + {name: "nested label", baseURL: "https://api.a.b.nullify.ai", wantErr: true}, + {name: "leading hyphen", baseURL: "https://api.-acme.nullify.ai", wantErr: true}, + {name: "not nullify", baseURL: "https://api.acme.example.com", wantErr: true}, + {name: "nullify lookalike", baseURL: "https://api.acme.nullify.ai.evil.com", wantErr: true}, + {name: "empty", baseURL: "", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := dataHostFromBaseURL(tt.baseURL) + if tt.wantErr { + if err == nil { + t.Fatalf("dataHostFromBaseURL(%q) = %q, want an error", tt.baseURL, got) + } + return + } + if err != nil { + t.Fatalf("dataHostFromBaseURL(%q): %v", tt.baseURL, err) + } + if got != tt.want { + t.Errorf("dataHostFromBaseURL(%q) = %q, want %q", tt.baseURL, got, tt.want) + } + }) + } +} diff --git a/internal/lib/get_token.go b/internal/lib/get_token.go index 7138b99..8b31be4 100644 --- a/internal/lib/get_token.go +++ b/internal/lib/get_token.go @@ -18,6 +18,8 @@ import ( var ErrNoToken = errors.New("no token detected") +var errRouterGitHubExchange = errors.New("GitHub Actions token exchange is not available through nullify.ai - set --host or NULLIFY_HOST to .nullify.ai") + type githubToken struct { Token string `json:"accessToken"` } @@ -76,6 +78,10 @@ func resolveNullifyToken( if os.Getenv("GITHUB_ACTIONS") == "true" && githubTokenFlag != "" && os.Getenv("GITHUB_REPOSITORY") != "" { + if auth.IsRouterHost(nullifyHost) { + return "", errRouterGitHubExchange + } + repo := os.Getenv("GITHUB_REPOSITORY") logger.L(ctx).Debug( diff --git a/internal/lib/get_token_test.go b/internal/lib/get_token_test.go index 38462f0..286fa5c 100644 --- a/internal/lib/get_token_test.go +++ b/internal/lib/get_token_test.go @@ -87,3 +87,19 @@ func TestRefreshNullifyTokenRejectsFixedSources(t *testing.T) { }) } } + +func TestGetNullifyTokenRefusesGitHubExchangeOnRouter(t *testing.T) { + ctx, err := logger.ConfigureDevelopmentLogger(context.Background(), "error") + require.NoError(t, err) + t.Setenv("HOME", t.TempDir()) + t.Setenv("NULLIFY_TOKEN", "") + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("GITHUB_REPOSITORY", "acme/repo") + + for _, host := range []string{"nullify.ai", "api.nullify.ai", "app.nullify.ai"} { + t.Run(host, func(t *testing.T) { + _, err := GetNullifyToken(ctx, host, "", "gh-token") + require.ErrorIs(t, err, errRouterGitHubExchange) + }) + } +} diff --git a/internal/lib/urls.go b/internal/lib/urls.go index a25e3c0..0c986df 100644 --- a/internal/lib/urls.go +++ b/internal/lib/urls.go @@ -3,6 +3,8 @@ package lib import ( "errors" "strings" + + "github.com/nullify-platform/cli/internal/auth" ) // ParseCustomerDomain accepts various forms of customer input and returns @@ -10,12 +12,17 @@ import ( // - "acme" → "api.acme.nullify.ai" // - "acme.nullify.ai" → "api.acme.nullify.ai" // - "api.acme.nullify.ai" → "api.acme.nullify.ai" +// - "nullify.ai", "app.nullify.ai" or "api.nullify.ai" → "api.nullify.ai" func ParseCustomerDomain(input string) (string, error) { input = strings.TrimSpace(input) if input == "" { return "", errors.New("customer domain cannot be empty") } + if auth.IsRouterHost(input) { + return "api." + auth.RouterHost, nil + } + // Strip any scheme if strings.Contains(input, "://") { input = strings.Split(input, "://")[1] diff --git a/internal/lib/urls_test.go b/internal/lib/urls_test.go index e43c83e..d77330b 100644 --- a/internal/lib/urls_test.go +++ b/internal/lib/urls_test.go @@ -43,6 +43,21 @@ func TestParseCustomerDomain(t *testing.T) { input: "acme.example.com", wantErr: true, }, + { + name: "router bare host", + input: "nullify.ai", + want: "api.nullify.ai", + }, + { + name: "router app host", + input: "app.nullify.ai", + want: "api.nullify.ai", + }, + { + name: "router api host", + input: "api.nullify.ai", + want: "api.nullify.ai", + }, } for _, tt := range tests { @@ -95,6 +110,31 @@ func TestSanitizeNullifyHost(t *testing.T) { expectedHost: "acme.nullify.ai", wantErr: false, }, + { + name: "router bare host", + inputHost: "nullify.ai", + expectedHost: "nullify.ai", + }, + { + name: "router api host", + inputHost: "api.nullify.ai", + expectedHost: "nullify.ai", + }, + { + name: "router app host with scheme and path", + inputHost: "https://app.nullify.ai/dashboard?tab=1", + expectedHost: "nullify.ai", + }, + { + name: "router host in upper case", + inputHost: "HTTPS://API.NULLIFY.AI", + expectedHost: "nullify.ai", + }, + { + name: "tenant host is not the router", + inputHost: "app.acme.nullify.ai", + expectedHost: "app.acme.nullify.ai", + }, { name: "input host with invalid scheme delimiter", inputHost: "random:/|api.example.nullify.ai", diff --git a/internal/wizard/steps.go b/internal/wizard/steps.go index a161c96..0867b5c 100644 --- a/internal/wizard/steps.go +++ b/internal/wizard/steps.go @@ -29,17 +29,16 @@ func DomainStep() Step { }, Execute: func(ctx context.Context) error { if !stdinIsTTY() { - return fmt.Errorf("not a terminal; configure a host non-interactively with 'nullify auth login --host .nullify.ai'") + return fmt.Errorf("not a terminal; configure a host non-interactively with 'nullify auth login'") } reader := bufio.NewReader(os.Stdin) - fmt.Print(" Enter your Nullify customer name (e.g., 'acme'): ") + fmt.Print(" Enter your Nullify customer name (e.g., 'acme'), or press Enter to sign in through nullify.ai: ") input, err := reader.ReadString('\n') if err != nil { return fmt.Errorf("failed to read input: %w", err) } - input = strings.TrimSpace(input) - host, err := lib.ParseCustomerDomain(input) + host, err := domainFromInput(input) if err != nil { return fmt.Errorf("invalid domain: %w", err) } @@ -55,7 +54,15 @@ func DomainStep() Step { } } -// AuthStep checks if valid credentials exist and runs login if not. +func domainFromInput(input string) (string, error) { + input = strings.TrimSpace(input) + if input == "" || auth.IsRouterHost(input) { + return auth.RouterHost, nil + } + return lib.ParseCustomerDomain(input) +} + +// AuthStep checks if a usable session exists and runs login if not. func AuthStep() Step { return Step{ Name: "Authenticate with Nullify", @@ -64,8 +71,7 @@ func AuthStep() Step { if err != nil || cfg.Host == "" { return false } - _, err = auth.GetValidToken(ctx, cfg.Host) - return err == nil + return auth.CheckSession(ctx, cfg.Host) == nil }, Execute: func(ctx context.Context) error { cfg, err := auth.LoadConfig() @@ -73,7 +79,11 @@ func AuthStep() Step { return fmt.Errorf("no host configured - complete the domain step first") } - return auth.Login(ctx, cfg.Host) + return auth.Login(ctx, cfg.Host, auth.InstallationPicker{ + Interactive: stdinIsTTY(), + In: os.Stdin, + Out: os.Stdout, + }) }, } } @@ -215,8 +225,7 @@ func SummaryStep() Step { return nil } - _, err = auth.GetValidToken(ctx, cfg.Host) - if err != nil { + if err := auth.CheckSession(ctx, cfg.Host); err != nil { fmt.Println(" Skipped - authentication required to fetch security posture.") return nil } diff --git a/internal/wizard/wizard_test.go b/internal/wizard/wizard_test.go index 09471ba..826563a 100644 --- a/internal/wizard/wizard_test.go +++ b/internal/wizard/wizard_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/nullify-platform/cli/internal/auth" "github.com/stretchr/testify/require" ) @@ -77,3 +78,65 @@ func TestRunStopsOnError(t *testing.T) { require.Error(t, err) require.Equal(t, []string{"fails"}, executedSteps) } + +func TestDomainFromInput(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", "nullify.ai"}, + {" \n", "nullify.ai"}, + {"acme\n", "api.acme.nullify.ai"}, + {"app.nullify.ai", "nullify.ai"}, + {"https://api.nullify.ai", "nullify.ai"}, + } + for _, tt := range tests { + got, err := domainFromInput(tt.input) + require.NoError(t, err) + require.Equal(t, tt.want, got, tt.input) + } +} + +func TestAuthStepCheckRequiresUsableSession(t *testing.T) { + tests := []struct { + name string + configHost string + creds map[string]auth.HostCredentials + want bool + }{ + { + name: "tenant with token", + configHost: "api.acme.nullify.ai", + creds: map[string]auth.HostCredentials{"acme.nullify.ai": {AccessToken: "t"}}, + want: true, + }, + { + name: "router with selected installation", + configHost: "api.nullify.ai", + creds: map[string]auth.HostCredentials{"nullify.ai": {AccessToken: "t", DataHost: "acme.nullify.ai"}}, + want: true, + }, + { + name: "router without installation", + configHost: "nullify.ai", + creds: map[string]auth.HostCredentials{"nullify.ai": {AccessToken: "t"}}, + want: false, + }, + { + name: "no credentials", + configHost: "nullify.ai", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + require.NoError(t, auth.SaveConfig(&auth.Config{Host: tt.configHost})) + for h, c := range tt.creds { + require.NoError(t, auth.SaveHostCredentials(h, c)) + } + + require.Equal(t, tt.want, AuthStep().Check(context.Background())) + }) + } +}