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
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
139 changes: 97 additions & 42 deletions cmd/cli/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package cmd
import (
"encoding/json"
"fmt"
"maps"
"os"
"os/signal"
"slices"
"syscall"
"time"

Expand All @@ -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 <your-instance>.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 <your-instance>.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 <your-instance>.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",
Expand All @@ -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 <your-instance>.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 <your-instance>.nullify.ai'")
fmt.Println("No host configured. Run 'nullify auth login'")
return nil
}

Expand All @@ -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) {
Expand All @@ -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 <name>'")
return
}
fmt.Printf("Installation: %s (%s)\n", hostCreds.Installation, hostCreds.DataHost)
}

var tokenCmd = &cobra.Command{
Use: "token",
Short: "Print access token to stdout",
Expand All @@ -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 <your-instance>.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 <host>' to switch.")
fmt.Printf("Switched to installation %s (%s)\n", install.Name, install.DataHost)
return nil
}

if switchHost == "" {
listConfiguredHosts()
return nil
}

Expand All @@ -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 <host>' to switch hosts, or 'nullify auth switch --installation <name>' 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",
Expand All @@ -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) {
Expand Down
44 changes: 44 additions & 0 deletions cmd/cli/cmd/auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
43 changes: 26 additions & 17 deletions cmd/cli/cmd/mcp.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"context"
"fmt"
"strings"

Expand Down Expand Up @@ -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 {
Expand All @@ -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 != "" {
Expand Down
Loading
Loading