diff --git a/cmd/auth/login.go b/cmd/auth/login.go index deab15d286..0548ce6247 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "os" "runtime" "strings" "time" @@ -17,12 +16,14 @@ import ( "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/exec" + "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/config/experimental/auth/authconv" "github.com/databricks/databricks-sdk-go/credentials/u2m" browserpkg "github.com/pkg/browser" "github.com/spf13/cobra" + "golang.org/x/oauth2" ) func promptForProfile(ctx context.Context, defaultValue string) (string, error) { @@ -46,8 +47,23 @@ const ( minimalDbConnectVersion = "13.1" defaultTimeout = 1 * time.Hour authTypeDatabricksCLI = "databricks-cli" + discoveryFallbackTip = "\n\nTip: you can specify a workspace directly with: databricks auth login --host " ) +type discoveryPersistentAuth interface { + Challenge() error + Token() (*oauth2.Token, error) + Close() error +} + +var newDiscoveryOAuthArgument = u2m.NewBasicDiscoveryOAuthArgument + +var newDiscoveryPersistentAuth = func(ctx context.Context, opts ...u2m.PersistentAuthOption) (discoveryPersistentAuth, error) { + return u2m.NewPersistentAuth(ctx, opts...) +} + +var introspectToken = auth.IntrospectToken + func newLoginCommand(authArguments *auth.AuthArguments) *cobra.Command { defaultConfigPath := "~/.databrickscfg" if runtime.GOOS == "windows" { @@ -70,9 +86,11 @@ you can refer to the documentation linked below. GCP: https://docs.gcp.databricks.com/dev-tools/auth/index.html -This command requires a Databricks Host URL (using --host or as a positional argument -or implicitly inferred from the specified profile name) -and a profile name (using --profile) to be specified. If you don't specify these +If no host is provided (via --host, as a positional argument, or from an existing +profile), the CLI will open login.databricks.com where you can authenticate and +select a workspace. The workspace URL will be discovered automatically. + +A profile name (using --profile) can be specified. If you don't specify these values, you'll be prompted for values at runtime. While this command always logs you into the specified host, the runtime behaviour @@ -139,6 +157,18 @@ depends on the existing profiles you have set in your configuration file return err } + // If no host is available from any source, use the discovery flow + // via login.databricks.com. + if shouldUseDiscovery(authArguments.Host, args, existingProfile) { + if configureCluster { + return errors.New("--configure-cluster requires --host to be specified") + } + if configureServerless { + return errors.New("--configure-serverless requires --host to be specified") + } + return discoveryLogin(ctx, profileName, loginTimeout, scopes, existingProfile, getBrowserFunc(cmd)) + } + // Load unified host flags from the profile if not explicitly set via CLI flag if !cmd.Flag("experimental-is-unified-host").Changed && existingProfile != nil { authArguments.IsUnifiedHost = existingProfile.IsUnifiedHost @@ -158,15 +188,11 @@ depends on the existing profiles you have set in your configuration file switch { case scopes != "": // Explicit --scopes flag takes precedence. - for _, s := range strings.Split(scopes, ",") { - scopesList = append(scopesList, strings.TrimSpace(s)) - } + scopesList = splitScopes(scopes) case existingProfile != nil && existingProfile.Scopes != "": // Preserve scopes from the existing profile so re-login // uses the same scopes the user previously configured. - for _, s := range strings.Split(existingProfile.Scopes, ",") { - scopesList = append(scopesList, strings.TrimSpace(s)) - } + scopesList = splitScopes(existingProfile.Scopes) } oauthArgument, err := authArguments.ToOAuthArgument() @@ -249,7 +275,7 @@ depends on the existing profiles you have set in your configuration file WorkspaceID: authArguments.WorkspaceID, Experimental_IsUnifiedHost: authArguments.IsUnifiedHost, ClusterID: clusterID, - ConfigFile: os.Getenv("DATABRICKS_CONFIG_FILE"), + ConfigFile: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), ServerlessComputeID: serverlessComputeID, Scopes: scopesList, }, clearKeys...) @@ -399,6 +425,21 @@ func loadProfileByName(ctx context.Context, profileName string, profiler profile return nil, nil } +// shouldUseDiscovery returns true if the discovery flow should be used +// (no host available from any source). +func shouldUseDiscovery(hostFlag string, args []string, existingProfile *profile.Profile) bool { + if hostFlag != "" { + return false + } + if len(args) > 0 { + return false + } + if existingProfile != nil && existingProfile.Host != "" { + return false + } + return true +} + // openURLSuppressingStderr opens a URL in the browser while suppressing stderr output. // This prevents xdg-open error messages from being displayed to the user. func openURLSuppressingStderr(url string) error { @@ -415,6 +456,107 @@ func openURLSuppressingStderr(url string) error { return browserpkg.OpenURL(url) } +// discoveryLogin runs the login.databricks.com discovery flow. The user +// authenticates in the browser, selects a workspace, and the CLI receives +// the workspace host from the OAuth callback's iss parameter. +func discoveryLogin(ctx context.Context, profileName string, timeout time.Duration, scopes string, existingProfile *profile.Profile, browserFunc func(string) error) error { + arg, err := newDiscoveryOAuthArgument(profileName) + if err != nil { + return fmt.Errorf("setting up login.databricks.com: %w"+discoveryFallbackTip, err) + } + + scopesList := splitScopes(scopes) + + opts := []u2m.PersistentAuthOption{ + u2m.WithOAuthArgument(arg), + u2m.WithBrowser(browserFunc), + u2m.WithDiscoveryLogin(), + } + if len(scopesList) > 0 { + opts = append(opts, u2m.WithScopes(scopesList)) + } + + // Apply timeout before creating PersistentAuth so Challenge() respects it. + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + persistentAuth, err := newDiscoveryPersistentAuth(ctx, opts...) + if err != nil { + return fmt.Errorf("setting up login.databricks.com: %w"+discoveryFallbackTip, err) + } + defer persistentAuth.Close() + + cmdio.LogString(ctx, "Opening login.databricks.com in your browser...") + if err := persistentAuth.Challenge(); err != nil { + return fmt.Errorf("login via login.databricks.com failed: %w"+discoveryFallbackTip, err) + } + + discoveredHost := arg.GetDiscoveredHost() + + // Get the token for introspection + tok, err := persistentAuth.Token() + if err != nil { + return fmt.Errorf("retrieving token after login: %w", err) + } + + // Best-effort introspection for metadata + var workspaceID string + introspection, err := introspectToken(ctx, discoveredHost, tok.AccessToken) + if err != nil { + log.Debugf(ctx, "token introspection failed (non-fatal): %v", err) + } else { + // TODO: Save introspection.AccountID once the SDKs are ready to use + // account_id as part of the profile/cache key. Adding it now would break + // existing auth flows that don't expect account_id on workspace profiles. + workspaceID = introspection.WorkspaceID + + // Warn if the detected account_id differs from what's already saved in the profile. + if existingProfile != nil && existingProfile.AccountID != "" && introspection.AccountID != "" && + existingProfile.AccountID != introspection.AccountID { + log.Warnf(ctx, "detected account ID %q differs from existing profile account ID %q", + introspection.AccountID, existingProfile.AccountID) + } + } + + configFile := env.Get(ctx, "DATABRICKS_CONFIG_FILE") + err = databrickscfg.SaveToProfile(ctx, &config.Config{ + Profile: profileName, + Host: discoveredHost, + AuthType: authTypeDatabricksCLI, + WorkspaceID: workspaceID, + Scopes: scopesList, + ConfigFile: configFile, + }, oauthLoginClearKeys()...) + if err != nil { + if configFile != "" { + return fmt.Errorf("saving profile %q to %s: %w", profileName, configFile, err) + } + return fmt.Errorf("saving profile %q: %w", profileName, err) + } + + cmdio.LogString(ctx, fmt.Sprintf("Profile %s was successfully saved", profileName)) + return nil +} + +// splitScopes splits a comma-separated scopes string into a trimmed slice. +func splitScopes(scopes string) []string { + if scopes == "" { + return nil + } + var result []string + for _, s := range strings.Split(scopes, ",") { + scope := strings.TrimSpace(s) + if scope == "" { + continue + } + result = append(result, scope) + } + if len(result) == 0 { + return nil + } + return result +} + // oauthLoginClearKeys returns profile keys that should be explicitly removed // when performing an OAuth login. Derives auth credential fields dynamically // from the SDK's ConfigAttributes to stay in sync as new auth methods are added. diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 013786c4bf..62c18974a9 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -1,17 +1,45 @@ package auth import ( + "bytes" "context" + "errors" + "log/slog" + "os" + "path/filepath" + "sync" "testing" + "time" "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go/credentials/u2m" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" ) +// logBuffer is a thread-safe bytes.Buffer for capturing log output in tests. +type logBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (lb *logBuffer) Write(p []byte) (int, error) { + lb.mu.Lock() + defer lb.mu.Unlock() + return lb.buf.Write(p) +} + +func (lb *logBuffer) String() string { + lb.mu.Lock() + defer lb.mu.Unlock() + return lb.buf.String() +} + func loadTestProfile(t *testing.T, ctx context.Context, profileName string) *profile.Profile { profile, err := loadProfileByName(ctx, profileName, profile.DefaultProfiler) require.NoError(t, err) @@ -19,6 +47,27 @@ func loadTestProfile(t *testing.T, ctx context.Context, profileName string) *pro return profile } +type fakeDiscoveryPersistentAuth struct { + token *oauth2.Token + challengeErr error + tokenErr error +} + +func (f *fakeDiscoveryPersistentAuth) Challenge() error { + return f.challengeErr +} + +func (f *fakeDiscoveryPersistentAuth) Token() (*oauth2.Token, error) { + if f.tokenErr != nil { + return nil, f.tokenErr + } + return f.token, nil +} + +func (f *fakeDiscoveryPersistentAuth) Close() error { + return nil +} + func TestSetHostDoesNotFailWithNoDatabrickscfg(t *testing.T) { ctx := t.Context() ctx = env.Set(ctx, "DATABRICKS_CONFIG_FILE", "./imaginary-file/databrickscfg") @@ -255,3 +304,258 @@ func TestLoadProfileByNameAndClusterID(t *testing.T) { }) } } + +func TestShouldUseDiscovery(t *testing.T) { + tests := []struct { + name string + hostFlag string + args []string + existingProfile *profile.Profile + want bool + }{ + { + name: "no host from any source", + want: true, + }, + { + name: "host from flag", + hostFlag: "https://example.com", + want: false, + }, + { + name: "host from positional arg", + args: []string{"https://example.com"}, + want: false, + }, + { + name: "host from existing profile", + existingProfile: &profile.Profile{Host: "https://example.com"}, + want: false, + }, + { + name: "existing profile without host", + existingProfile: &profile.Profile{Name: "test"}, + want: true, + }, + { + name: "nil profile", + existingProfile: nil, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldUseDiscovery(tt.hostFlag, tt.args, tt.existingProfile) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSplitScopes(t *testing.T) { + tests := []struct { + name string + input string + output []string + }{ + { + name: "empty input", + input: "", + output: nil, + }, + { + name: "single scope", + input: "all-apis", + output: []string{"all-apis"}, + }, + { + name: "trims whitespace", + input: " all-apis , sql ", + output: []string{"all-apis", "sql"}, + }, + { + name: "drops empty entries", + input: "all-apis, ,sql,,", + output: []string{"all-apis", "sql"}, + }, + { + name: "only empty entries", + input: " , , ", + output: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.output, splitScopes(tt.input)) + }) + } +} + +func TestDiscoveryLogin_IntrospectionFailureStillSavesProfile(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".databrickscfg") + err := os.WriteFile(configPath, []byte(""), 0o600) + require.NoError(t, err) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + originalNewDiscoveryOAuthArgument := newDiscoveryOAuthArgument + originalNewDiscoveryPersistentAuth := newDiscoveryPersistentAuth + originalIntrospectToken := introspectToken + t.Cleanup(func() { + newDiscoveryOAuthArgument = originalNewDiscoveryOAuthArgument + newDiscoveryPersistentAuth = originalNewDiscoveryPersistentAuth + introspectToken = originalIntrospectToken + }) + + newDiscoveryOAuthArgument = func(profileName string) (*u2m.BasicDiscoveryOAuthArgument, error) { + arg, err := u2m.NewBasicDiscoveryOAuthArgument(profileName) + if err != nil { + return nil, err + } + arg.SetDiscoveredHost("https://workspace.example.com") + return arg, nil + } + + newDiscoveryPersistentAuth = func(ctx context.Context, opts ...u2m.PersistentAuthOption) (discoveryPersistentAuth, error) { + return &fakeDiscoveryPersistentAuth{ + token: &oauth2.Token{AccessToken: "test-token"}, + }, nil + } + + introspectToken = func(ctx context.Context, host, accessToken string) (*auth.IntrospectionResult, error) { + assert.Equal(t, "https://workspace.example.com", host) + assert.Equal(t, "test-token", accessToken) + return nil, errors.New("introspection failed") + } + + ctx, _ := cmdio.NewTestContextWithStdout(t.Context()) + err = discoveryLogin(ctx, "DISCOVERY", time.Second, "all-apis, ,sql,", nil, func(string) error { return nil }) + require.NoError(t, err) + + savedProfile, err := loadProfileByName(ctx, "DISCOVERY", profile.DefaultProfiler) + require.NoError(t, err) + require.NotNil(t, savedProfile) + assert.Equal(t, "https://workspace.example.com", savedProfile.Host) + assert.Equal(t, "all-apis,sql", savedProfile.Scopes) + assert.Empty(t, savedProfile.AccountID) + assert.Empty(t, savedProfile.WorkspaceID) +} + +func TestDiscoveryLogin_AccountIDMismatchWarning(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".databrickscfg") + err := os.WriteFile(configPath, []byte(""), 0o600) + require.NoError(t, err) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + originalNewDiscoveryOAuthArgument := newDiscoveryOAuthArgument + originalNewDiscoveryPersistentAuth := newDiscoveryPersistentAuth + originalIntrospectToken := introspectToken + t.Cleanup(func() { + newDiscoveryOAuthArgument = originalNewDiscoveryOAuthArgument + newDiscoveryPersistentAuth = originalNewDiscoveryPersistentAuth + introspectToken = originalIntrospectToken + }) + + newDiscoveryOAuthArgument = func(profileName string) (*u2m.BasicDiscoveryOAuthArgument, error) { + arg, err := u2m.NewBasicDiscoveryOAuthArgument(profileName) + if err != nil { + return nil, err + } + arg.SetDiscoveredHost("https://workspace.example.com") + return arg, nil + } + + newDiscoveryPersistentAuth = func(ctx context.Context, opts ...u2m.PersistentAuthOption) (discoveryPersistentAuth, error) { + return &fakeDiscoveryPersistentAuth{ + token: &oauth2.Token{AccessToken: "test-token"}, + }, nil + } + + introspectToken = func(ctx context.Context, host, accessToken string) (*auth.IntrospectionResult, error) { + return &auth.IntrospectionResult{ + AccountID: "new-account-id", + WorkspaceID: "12345", + }, nil + } + + // Set up a logger that captures log records to verify the warning. + var logBuf logBuffer + logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn})) + ctx, _ := cmdio.NewTestContextWithStdout(t.Context()) + ctx = log.NewContext(ctx, logger) + + existingProfile := &profile.Profile{ + Name: "DISCOVERY", + AccountID: "old-account-id", + } + + err = discoveryLogin(ctx, "DISCOVERY", time.Second, "", existingProfile, func(string) error { return nil }) + require.NoError(t, err) + + // Verify warning about mismatched account IDs was logged. + assert.Contains(t, logBuf.String(), "new-account-id") + assert.Contains(t, logBuf.String(), "old-account-id") + + // Verify the profile was saved without account_id (not overwritten). + savedProfile, err := loadProfileByName(ctx, "DISCOVERY", profile.DefaultProfiler) + require.NoError(t, err) + require.NotNil(t, savedProfile) + assert.Equal(t, "https://workspace.example.com", savedProfile.Host) + assert.Equal(t, "12345", savedProfile.WorkspaceID) +} + +func TestDiscoveryLogin_NoWarningWhenAccountIDsMatch(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".databrickscfg") + err := os.WriteFile(configPath, []byte(""), 0o600) + require.NoError(t, err) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + originalNewDiscoveryOAuthArgument := newDiscoveryOAuthArgument + originalNewDiscoveryPersistentAuth := newDiscoveryPersistentAuth + originalIntrospectToken := introspectToken + t.Cleanup(func() { + newDiscoveryOAuthArgument = originalNewDiscoveryOAuthArgument + newDiscoveryPersistentAuth = originalNewDiscoveryPersistentAuth + introspectToken = originalIntrospectToken + }) + + newDiscoveryOAuthArgument = func(profileName string) (*u2m.BasicDiscoveryOAuthArgument, error) { + arg, err := u2m.NewBasicDiscoveryOAuthArgument(profileName) + if err != nil { + return nil, err + } + arg.SetDiscoveredHost("https://workspace.example.com") + return arg, nil + } + + newDiscoveryPersistentAuth = func(ctx context.Context, opts ...u2m.PersistentAuthOption) (discoveryPersistentAuth, error) { + return &fakeDiscoveryPersistentAuth{ + token: &oauth2.Token{AccessToken: "test-token"}, + }, nil + } + + introspectToken = func(ctx context.Context, host, accessToken string) (*auth.IntrospectionResult, error) { + return &auth.IntrospectionResult{ + AccountID: "same-account-id", + WorkspaceID: "12345", + }, nil + } + + var logBuf logBuffer + logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn})) + ctx, _ := cmdio.NewTestContextWithStdout(t.Context()) + ctx = log.NewContext(ctx, logger) + + existingProfile := &profile.Profile{ + Name: "DISCOVERY", + AccountID: "same-account-id", + } + + err = discoveryLogin(ctx, "DISCOVERY", time.Second, "", existingProfile, func(string) error { return nil }) + require.NoError(t, err) + + // No warning should be logged when account IDs match. + assert.Empty(t, logBuf.String()) +} diff --git a/cmd/auth/token.go b/cmd/auth/token.go index ca8582bd02..bd320c9ea3 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -434,9 +434,7 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler) (string, *pr // uses the same scopes the user previously configured. var scopesList []string if existingProfile != nil && existingProfile.Scopes != "" { - for _, s := range strings.Split(existingProfile.Scopes, ",") { - scopesList = append(scopesList, strings.TrimSpace(s)) - } + scopesList = splitScopes(existingProfile.Scopes) } oauthArgument, err := loginArgs.ToOAuthArgument() diff --git a/libs/auth/introspect.go b/libs/auth/introspect.go new file mode 100644 index 0000000000..bd4deaae84 --- /dev/null +++ b/libs/auth/introspect.go @@ -0,0 +1,66 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +// IntrospectionResponse represents the response from the Databricks token +// introspection endpoint at /api/2.0/tokens/introspect. +type IntrospectionResponse struct { + PrincipalContext struct { + AuthenticationScope struct { + AccountID string `json:"account_id"` + WorkspaceID int64 `json:"workspace_id"` + } `json:"authentication_scope"` + } `json:"principal_context"` +} + +// IntrospectionResult contains the extracted metadata from token introspection. +type IntrospectionResult struct { + AccountID string + WorkspaceID string +} + +// IntrospectToken calls the workspace token introspection endpoint to extract +// account_id and workspace_id for the given access token. Returns an error +// if the request fails or the response cannot be parsed. Callers should treat +// errors as non-fatal (best-effort metadata enrichment). +func IntrospectToken(ctx context.Context, host, accessToken string) (*IntrospectionResult, error) { + endpoint := strings.TrimSuffix(host, "/") + "/api/2.0/tokens/introspect" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("creating introspection request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("calling introspection endpoint: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // Drain the body so the underlying TCP connection can be reused. + _, _ = io.Copy(io.Discard, resp.Body) + return nil, fmt.Errorf("introspection endpoint returned status %d", resp.StatusCode) + } + + var introspection IntrospectionResponse + if err := json.NewDecoder(resp.Body).Decode(&introspection); err != nil { + return nil, fmt.Errorf("decoding introspection response: %w", err) + } + + result := &IntrospectionResult{ + AccountID: introspection.PrincipalContext.AuthenticationScope.AccountID, + } + if introspection.PrincipalContext.AuthenticationScope.WorkspaceID != 0 { + result.WorkspaceID = strconv.FormatInt(introspection.PrincipalContext.AuthenticationScope.WorkspaceID, 10) + } + return result, nil +} diff --git a/libs/auth/introspect_test.go b/libs/auth/introspect_test.go new file mode 100644 index 0000000000..d6ce2e83e6 --- /dev/null +++ b/libs/auth/introspect_test.go @@ -0,0 +1,84 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIntrospectToken_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "principal_context": { + "authentication_scope": { + "account_id": "a1b1c234-5678-90ab-cdef-1234567890ab", + "workspace_id": 2548836972759138 + } + } + }`)) + })) + defer server.Close() + + result, err := IntrospectToken(t.Context(), server.URL, "test-token") + require.NoError(t, err) + assert.Equal(t, "a1b1c234-5678-90ab-cdef-1234567890ab", result.AccountID) + assert.Equal(t, "2548836972759138", result.WorkspaceID) +} + +func TestIntrospectToken_ZeroWorkspaceID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "principal_context": { + "authentication_scope": { + "account_id": "abc-123", + "workspace_id": 0 + } + } + }`)) + })) + defer server.Close() + + result, err := IntrospectToken(t.Context(), server.URL, "test-token") + require.NoError(t, err) + assert.Equal(t, "abc-123", result.AccountID) + assert.Empty(t, result.WorkspaceID) +} + +func TestIntrospectToken_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + _, err := IntrospectToken(t.Context(), server.URL, "test-token") + assert.ErrorContains(t, err, "status 403") +} + +func TestIntrospectToken_MalformedJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`not json`)) + })) + defer server.Close() + + _, err := IntrospectToken(t.Context(), server.URL, "test-token") + assert.ErrorContains(t, err, "decoding introspection response") +} + +func TestIntrospectToken_VerifyRequestDetails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/2.0/tokens/introspect", r.URL.Path) + assert.Equal(t, "Bearer my-secret-token", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"principal_context":{"authentication_scope":{"account_id":"x","workspace_id":1}}}`)) + })) + defer server.Close() + + _, err := IntrospectToken(t.Context(), server.URL, "my-secret-token") + require.NoError(t, err) +}