diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index abada85..635bbca 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dash0-agent-plugin", - "version": "0.1.5", + "version": "0.2.0", "description": "OpenTelemetry observability for Claude Code sessions. Captures tool calls, LLM invocations, token usage, and errors as OTel traces.", "author": { "name": "Dash0" @@ -11,17 +11,23 @@ "keywords": ["observability", "tracing", "opentelemetry", "dash0", "otel"], "userConfig": { "OTLP_URL": { - "title": "OTLP Endpoint URL", - "description": "Dash0 OTLP endpoint URL (e.g. https://ingress.us1.dash0.com:4318)", + "title": "OTLP Endpoint URL (optional)", + "description": "Override the Dash0 OTLP endpoint URL (e.g. https://ingress.us1.dash0.com:4318). Leave blank to use the ingestion URL recorded by /dash0-agent-plugin:login.", "type": "string", "sensitive": false }, "AUTH_TOKEN": { - "title": "Auth Token", - "description": "Dash0 authentication token", + "title": "Auth Token (optional — use /dash0-agent-plugin:login instead)", + "description": "Dash0 authentication token. Prefer running /dash0-agent-plugin:login; only set this manually for CI or shared environments where the browser flow is not possible.", "type": "string", "sensitive": true }, + "AUTH_URL": { + "title": "OAuth Authorization Server URL (advanced)", + "description": "Override the OAuth authorization server used by /dash0-agent-plugin:login. Defaults to the Dash0 regional API derived from OTLP_URL (e.g. https://api.eu-west-1.aws.dash0.com). Only set this for non-standard Dash0 deployments.", + "type": "string", + "sensitive": false + }, "DATASET": { "title": "Dataset", "description": "Dash0 dataset name (optional)", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 256e5fb..0603c3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,20 +91,36 @@ jobs: fi go test -tags=e2e -v -timeout=60s ./test/e2e/ + auth-e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Go + uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5 + with: + go-version-file: go.mod + + - name: Run mocked auth-flow tests + run: go test -tags=e2e -count=1 -timeout=60s -run TestAuthFlow ./test/e2e/ -v + consistency-checks: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Version consistency (plugin.json == on-event.sh) + - name: Version consistency (plugin.json == on-event.sh == login.sh) run: | PLUGIN_VERSION=$(jq -r '.version' .claude-plugin/plugin.json) - SCRIPT_VERSION=$(grep '^VERSION=' scripts/on-event.sh | sed 's/VERSION="//' | sed 's/"//') + EVENT_VERSION=$(grep '^VERSION=' scripts/on-event.sh | sed 's/VERSION="//' | sed 's/"//') + LOGIN_VERSION=$(grep '^VERSION=' scripts/login.sh | sed 's/VERSION="//' | sed 's/"//') echo "plugin.json: $PLUGIN_VERSION" - echo "on-event.sh: $SCRIPT_VERSION" - if [ "$PLUGIN_VERSION" != "$SCRIPT_VERSION" ]; then - echo "::error::Version mismatch: plugin.json=$PLUGIN_VERSION, on-event.sh=$SCRIPT_VERSION" + echo "on-event.sh: $EVENT_VERSION" + echo "login.sh: $LOGIN_VERSION" + if [ "$PLUGIN_VERSION" != "$EVENT_VERSION" ] || [ "$PLUGIN_VERSION" != "$LOGIN_VERSION" ]; then + echo "::error::Version mismatch: plugin.json=$PLUGIN_VERSION, on-event.sh=$EVENT_VERSION, login.sh=$LOGIN_VERSION" exit 1 fi @@ -155,16 +171,17 @@ jobs: - name: Hook script is valid run: | - # Check on-event.sh is executable and has valid shebang - if [ ! -x scripts/on-event.sh ]; then - echo "::error::scripts/on-event.sh is not executable" - exit 1 - fi - HEAD=$(head -1 scripts/on-event.sh) - if [[ "$HEAD" != "#!/"* ]]; then - echo "::error::scripts/on-event.sh missing shebang" - exit 1 - fi + for SCRIPT in scripts/on-event.sh scripts/login.sh; do + if [ ! -x "$SCRIPT" ]; then + echo "::error::$SCRIPT is not executable" + exit 1 + fi + HEAD=$(head -1 "$SCRIPT") + if [[ "$HEAD" != "#!/"* ]]; then + echo "::error::$SCRIPT missing shebang" + exit 1 + fi + done # Check hooks.json references an existing script HOOK_CMD=$(jq -r '.hooks.SessionStart[0].hooks[0].command' hooks/hooks.json) EXPECTED='${CLAUDE_PLUGIN_ROOT}/scripts/on-event.sh' diff --git a/.gitignore b/.gitignore index 4e1a835..e32b86b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,8 @@ .conductor/ events.jsonl dist/ -bin/ \ No newline at end of file +bin/ + +# Session-scoped data directories created by the hook when CLAUDE_PLUGIN_DATA +# is set to the repo root (e.g. during local dev via .claude/settings.json). +[0-9a-f]*-[0-9a-f]*-[0-9a-f]*-[0-9a-f]*-[0-9a-f]*/ \ No newline at end of file diff --git a/README.md b/README.md index a7185ce..cec290f 100644 --- a/README.md +++ b/README.md @@ -23,21 +23,35 @@ Claude Code plugin that captures agent activity as OpenTelemetry traces — tool ### First-time setup -After installing, **the plugin does not start sending telemetry until you complete two steps**: +After installing, sign in with your browser: -1. **Configure credentials.** Run `/plugin` → **Installed** → **dash0** → **Configure**. Enter: - - `OTLP_URL` — your Dash0 OTLP endpoint, e.g. `https://ingress.us1.dash0.com:4318` - - `AUTH_TOKEN` — your Dash0 auth token (sensitive — stored in your OS keychain, not in `settings.json`) - - `DATASET` *(optional)* - - `AGENT_NAME` *(optional)* -2. **Reload the running session.** Run `/reload-plugins`. Without this, the current session's hooks still have empty config and silently emit nothing. +``` +/dash0-agent-plugin:login +``` + +A Dash0 sign-in page opens in your browser. **New to Dash0?** Click **Sign up** on the next page to start a free trial. The plugin signs in against `https://api.eu-west-1.aws.dash0.com` (the EU-West regional API) by default; override with `--auth-url ` or `DASH0_AUTH_URL=` (e.g. for a dev environment or a different region). + +Once you've signed in: +- A long-lived ingestion token is minted and saved to your OS config dir under `dash0/credentials.json` (mode `0600`): `~/Library/Application Support/dash0/` on macOS, `$XDG_CONFIG_HOME/dash0` or `~/.config/dash0` on Linux, `%AppData%\dash0\` on Windows. +- Your organization's ingestion URL is recorded automatically; you don't need to set `OTLP_URL` unless you're self-hosting. +- The next time a Claude Code session starts, you'll see `dash0: connected`. -If you start a session before completing setup, the plugin writes this line to stderr on `SessionStart`: +If you start a session before logging in, the plugin prints the hint: ``` -dash0: not configured — no OTLP_URL set. In Claude Code: /plugin → Installed → dash0 → Configure, then /reload-plugins. +dash0: not authenticated — run /dash0-agent-plugin:login to sign in or start a free trial. ``` +#### Advanced: pre-existing token (CI, shared machines) + +If you have a Dash0 ingestion token already and prefer not to use the browser flow, you can paste it via `/plugin` → **Installed** → **dash0** → **Configure**: + +- `AUTH_TOKEN` — your Dash0 token (sensitive — stored in your OS keychain) +- `OTLP_URL` — explicit OTLP endpoint (overrides what `/dash0-agent-plugin:login` recorded) +- `DATASET`, `AGENT_NAME` — optional + +Manually-set values take precedence over what `/dash0-agent-plugin:login` wrote. + ### Local development ```bash @@ -128,18 +142,29 @@ The plugin declares its configuration via Claude Code's `userConfig` mechanism. | Option | Description | Required | Sensitive | |---|---|---|---| -| `OTLP_URL` | Dash0 OTLP endpoint URL (e.g. `https://ingress.us1.dash0.com:4318`) | Yes | No | -| `AUTH_TOKEN` | Dash0 authentication token | Yes | Yes (stored in keychain) | +| `OTLP_URL` | Override the Dash0 OTLP endpoint URL (e.g. `https://ingress.us1.dash0.com:4318`). Populated automatically by `/dash0-agent-plugin:login`; only set this manually for self-hosting or custom endpoints. | No (set via login) | No | +| `AUTH_TOKEN` | Dash0 authentication token. Prefer `/dash0-agent-plugin:login` over setting this manually. | No (set via login) | Yes (stored in keychain) | | `DATASET` | Dash0 dataset name | No | No | | `AGENT_NAME` | Used as `service.name` and `gen_ai.agent.name` resource attributes (defaults to `claude-code`) | No | No | +### Authentication storage + +`/dash0-agent-plugin:login` writes two files (mode `0600`) under the OS config dir (`~/Library/Application Support/dash0/` on macOS, `$XDG_CONFIG_HOME/dash0` or `~/.config/dash0` on Linux, `%AppData%\dash0\` on Windows): + +| File | Contents | +|---|---| +| `credentials.json` | Minted machine token, organization ID, auth URL, and ingestion URL. Read by the hook on every event — **no `/reload-plugins` required** after re-login. | +| `clients.json` | OAuth Dynamic Client Registration result, keyed by auth URL. Reused across logins so the plugin doesn't re-register on every run. | + +Delete `credentials.json` from your OS config dir and re-run `/dash0-agent-plugin:login` to switch organizations. + After changing any value via Configure, run `/reload-plugins` to apply it to the current session. ### Environment variable fallback For non-sensitive options, the plugin falls back to `DASH0_*` environment variables when the `userConfig` value is not set. This is useful for `--plugin-dir` development or CI. -> **Note:** `AUTH_TOKEN` has no env var fallback — it must be configured via `/plugin → Configure` (stored in the OS keychain). This prevents the token from leaking into tool-spawned shell environments where other tools (e.g. Dash0 CLI) might pick it up. +> **Note:** `AUTH_TOKEN` has no env var fallback — it must be configured via `/plugin → Configure` (stored in the OS keychain) or by running `/dash0-agent-plugin:login`. This prevents the token from leaking into tool-spawned shell environments where other tools (e.g. Dash0 CLI) might pick it up. | Variable | Description | |---|---| @@ -150,6 +175,9 @@ For non-sensitive options, the plugin falls back to `DASH0_*` environment variab | `DASH0_OMIT_IO` | Omit prompts and tool I/O (default: `true`). When true, prompt content and tool call inputs/outputs are stripped from spans. Set to `false` to include full content. | | `DASH0_DEBUG` | Print OTel payloads to stderr for local debugging (`true`/`false`) | | `DASH0_DEBUG_FILE` | Also write debug output to this file path (e.g. `/tmp/dash0-debug.log`) | +| `DASH0_CONFIG_DIR` | Override the directory for `credentials.json` / `clients.json` (defaults to OS config dir / `dash0`). Mainly for testing. | +| `DASH0_AUTH_URL` | Dash0 regional API URL for `/dash0-agent-plugin:login` (default: `https://api.eu-west-1.aws.dash0.com`). Inferred as `https://api.eu-west-1.aws.dash0-dev.com` when `DASH0_OTLP_URL` points at a `.dash0-dev.com` host. | +| `DASH0_AUTH_NO_BROWSER` | When `1`, `/dash0-agent-plugin:login` prints the authorize URL instead of opening a browser. Useful for headless setups. | ### Per-project overrides diff --git a/cmd/on-event/login.go b/cmd/on-event/login.go new file mode 100644 index 0000000..4b07758 --- /dev/null +++ b/cmd/on-event/login.go @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +package main + +import ( + "context" + "flag" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/dash0hq/dash0-agent-plugin/internal/auth" + "github.com/dash0hq/dash0-agent-plugin/internal/version" +) + +const ( + // Dash0 regional OAuth server endpoints. The regional API hosts the + // OAuth server, issues dash0_at_* tokens, and supports DCR (RFC 7591). + defaultAuthURL = "https://api.eu-west-1.aws.dash0.com" + defaultAuthURLDev = "https://api.eu-west-1.aws.dash0-dev.com" +) + +func runLogin(args []string) error { + fs := flag.NewFlagSet("login", flag.ContinueOnError) + authURLFlag := fs.String("auth-url", "", "OAuth authorization server root (default: inferred from OTLP_URL or DASH0_AUTH_URL, else "+defaultAuthURL+")") + timeout := fs.Duration("timeout", 5*time.Minute, "How long to wait for the browser redirect") + if err := fs.Parse(args); err != nil { + return err + } + + authURL := resolveAuthURLForLogin(*authURLFlag) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + opts := auth.LoginOptions{ + AuthURL: authURL, + ClientName: "Dash0 Claude Code Plugin", + ClientURI: "https://github.com/dash0hq/dash0-agent-plugin", + CallbackTimeout: *timeout, + Stdout: os.Stdout, + Stderr: os.Stderr, + } + if v := version.Version; v != "" { + opts.ClientName = fmt.Sprintf("Dash0 Claude Code Plugin %s", v) + } + + if _, err := auth.Login(ctx, opts); err != nil { + return err + } + return nil +} + +// resolveAuthURLForLogin picks the OAuth host based on (in order): +// 1. --auth-url flag +// 2. CLAUDE_PLUGIN_OPTION_AUTH_URL / DASH0_AUTH_URL env +// 3. OTLP_URL hostname: replaces "ingress." prefix with "api." to get the +// regional API OAuth server (e.g. ingress.eu-west-1.aws.dash0.com → +// api.eu-west-1.aws.dash0.com) +// 4. defaultAuthURL (EU prod) +func resolveAuthURLForLogin(flagValue string) string { + if v := strings.TrimSpace(flagValue); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("CLAUDE_PLUGIN_OPTION_AUTH_URL")); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("DASH0_AUTH_URL")); v != "" { + return v + } + otlp := os.Getenv("CLAUDE_PLUGIN_OPTION_OTLP_URL") + if otlp == "" { + otlp = os.Getenv("DASH0_OTLP_URL") + } + if otlp != "" { + if u, err := url.Parse(otlp); err == nil { + host := u.Hostname() + if strings.HasPrefix(host, "ingress.") { + return "https://api." + strings.TrimPrefix(host, "ingress.") + } + } + } + return defaultAuthURL +} diff --git a/cmd/on-event/main.go b/cmd/on-event/main.go index 8f2e4ba..a673f4f 100644 --- a/cmd/on-event/main.go +++ b/cmd/on-event/main.go @@ -3,6 +3,7 @@ package main import ( "bytes" "compress/zlib" + "context" "encoding/base64" "encoding/json" "fmt" @@ -13,6 +14,7 @@ import ( "strings" "time" + "github.com/dash0hq/dash0-agent-plugin/internal/auth" "github.com/dash0hq/dash0-agent-plugin/internal/dotenv" "github.com/dash0hq/dash0-agent-plugin/internal/filelog" "github.com/dash0hq/dash0-agent-plugin/internal/otlp" @@ -20,6 +22,13 @@ import ( ) func main() { + if len(os.Args) > 1 && os.Args[1] == "login" { + if err := runLogin(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "dash0: %v\n", err) + os.Exit(1) + } + return + } if err := run(); err != nil { fmt.Fprintf(os.Stderr, "on-event: %v\n", err) os.Exit(1) @@ -172,7 +181,6 @@ func sendLLMTrace(event map[string]any, cfg otlp.Config, ts time.Time, dataDir s return otlp.SendTrace(span, event, cfg) } - // extractAgentIDFromResponse parses the agentId from an Agent tool's response. // The response may be a JSON string or an already-decoded map. func extractAgentIDFromResponse(v any) string { @@ -217,13 +225,12 @@ func deriveAppURL(otlpURL string) string { } host := u.Hostname() switch { - case strings.HasSuffix(host, ".dash0.com"): - return "https://app.dash0.com" case strings.HasSuffix(host, ".dash0-dev.com"): return "https://app.dash0-dev.com" - default: - return "" + case strings.HasSuffix(host, ".dash0.com"): + return "https://app.dash0.com" } + return "" } // buildSessionURL constructs a full Dash0 session details URL with the encoded @@ -290,6 +297,59 @@ func pluginOptionBoolDefault(key string, defaultVal bool) bool { return v == "true" || v == "1" } +// resolveAuthToken returns the auth token for OTLP ingestion. Precedence: +// 1. CLAUDE_PLUGIN_OPTION_AUTH_TOKEN (manual paste in /plugin Configure) +// 2. credentials.json IngestionToken (auth_* provisioned at login) +// 3. credentials.json AuthToken (OAuth access token fallback) +func resolveAuthToken(creds *auth.Credentials) string { + if v := pluginOptionSecure("AUTH_TOKEN"); v != "" { + return v + } + if creds != nil { + if creds.IngestionToken != "" { + return creds.IngestionToken + } + return creds.AuthToken + } + return "" +} + +// resolveOtlpURL picks the OTLP ingestion URL. Precedence: +// 1. CLAUDE_PLUGIN_OPTION_OTLP_URL / DASH0_OTLP_URL (explicit override) +// 2. credentials.json ingress_url (recorded at login) +func resolveOtlpURL(creds *auth.Credentials) string { + if v := pluginOption("OTLP_URL"); v != "" { + return v + } + if creds != nil && creds.IngressURL != "" { + return creds.IngressURL + } + return "" +} + +// loadCredentialsForHook is a non-fatal credential reader for hook events. +// Returns nil on any error — the hook should still emit telemetry where +// possible, just without the auto-discovered region/token. +func loadCredentialsForHook() *auth.Credentials { + creds, err := auth.LoadCredentials() + if err != nil { + fmt.Fprintf(os.Stderr, "on-event: reading credentials: %v\n", err) + return nil + } + return creds +} + +// isUnauthorized reports whether an OTLP connectivity-check error indicates +// a rejected bearer token (HTTP 401 or 403). Matches the error format used +// by sendOTLP in internal/otlp. +func isUnauthorized(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "401") || strings.Contains(msg, "403") +} + func run() error { dotenv.Load(".env") @@ -383,9 +443,10 @@ func run() error { return err } + creds := loadCredentialsForHook() cfg := otlp.Config{ - OTLPUrl: pluginOption("OTLP_URL"), - AuthToken: pluginOptionSecure("AUTH_TOKEN"), + OTLPUrl: resolveOtlpURL(creds), + AuthToken: resolveAuthToken(creds), Dataset: pluginOption("DATASET"), AgentName: pluginOption("AGENT_NAME"), OmitUserInfo: pluginOptionBoolDefault("OMIT_USER_INFO", true), @@ -403,18 +464,55 @@ func run() error { } if hookEvent == "SessionStart" { - if cfg.OTLPUrl == "" { + const loginNudge = "The Dash0 agent plugin is installed but the user is not authenticated against Dash0. Telemetry will not be sent until they sign in. Before proceeding with whatever the user actually asked for, invoke the `dash0-agent-plugin:login` slash command (via the Skill tool) so the OAuth browser flow can complete. Then continue with the user's original request." + orgSuffix := "" + if creds != nil && creds.OrganizationTechnicalID != "" { + orgSuffix = " as " + creds.OrganizationTechnicalID + } + switch { + case cfg.OTLPUrl == "": printHookResponse( - "dash0: telemetry is not active — configure the plugin to start sending data. Run /plugin → Installed → dash0 → Configure, then /reload-plugins.", - "", + "dash0: telemetry is not active — sign in by running /dash0-agent-plugin:login (or set OTLP_URL manually via /plugin → Configure).", + loginNudge, ) - } else if err := otlp.CheckConnectivity(cfg); err != nil { + case cfg.AuthToken == "": printHookResponse( - fmt.Sprintf("dash0: connectivity check failed — %v", err), - "", + "dash0: not authenticated — run /dash0-agent-plugin:login to sign in or start a free trial.", + loginNudge, ) - } else { - printHookResponse("dash0: connected", "") + default: + refreshed := false + if err := otlp.CheckConnectivity(cfg); err != nil { + if isUnauthorized(err) && creds != nil && creds.RefreshToken != "" { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + nc, rerr := auth.RefreshCredentials(ctx, creds) + cancel() + if rerr == nil { + creds = nc + cfg.AuthToken = nc.AuthToken + err = otlp.CheckConnectivity(cfg) + refreshed = true + } else { + fmt.Fprintf(os.Stderr, "on-event: refresh failed: %v\n", rerr) + } + } + if err != nil { + if isUnauthorized(err) { + printHookResponse( + "dash0: auth token rejected — run /dash0-agent-plugin:login to re-authenticate.", + loginNudge, + ) + } else { + printHookResponse(fmt.Sprintf("dash0: connectivity check failed — %v", err), "") + } + } else if refreshed { + printHookResponse("dash0: connected"+orgSuffix+" (token refreshed)", "") + } else { + printHookResponse("dash0: connected"+orgSuffix, "") + } + } else { + printHookResponse("dash0: connected"+orgSuffix, "") + } } } diff --git a/cmd/on-event/main_test.go b/cmd/on-event/main_test.go index 1b89e30..8951139 100644 --- a/cmd/on-event/main_test.go +++ b/cmd/on-event/main_test.go @@ -649,8 +649,10 @@ func TestDeriveAppURL(t *testing.T) { func TestSessionStartHintWhenNotConfigured(t *testing.T) { dataDir := t.TempDir() + configDir := t.TempDir() env := append(os.Environ(), "CLAUDE_PLUGIN_DATA="+dataDir, + "DASH0_CONFIG_DIR="+configDir, // No OTLP_URL via either mechanism. Hint should fire on SessionStart. "DASH0_OTLP_URL=", "CLAUDE_PLUGIN_OPTION_OTLP_URL=", @@ -658,28 +660,50 @@ func TestSessionStartHintWhenNotConfigured(t *testing.T) { stdout, _ := execBinary(t, `{"hook_event_name":"SessionStart","session_id":"sess-unconfigured","model":"opus"}`, env) assert.Contains(t, stdout, `"systemMessage"`) assert.Contains(t, stdout, "telemetry is not active") - assert.Contains(t, stdout, "/reload-plugins") + assert.Contains(t, stdout, "/dash0-agent-plugin:login") +} + +func TestSessionStartHintWhenUnauthenticated(t *testing.T) { + dataDir := t.TempDir() + configDir := t.TempDir() + srv, _, _ := collectingServer(t) + env := append(os.Environ(), + "CLAUDE_PLUGIN_DATA="+dataDir, + "DASH0_CONFIG_DIR="+configDir, + "DASH0_OTLP_URL="+srv.URL, + "CLAUDE_PLUGIN_OPTION_AUTH_TOKEN=", + ) + stdout, _ := execBinary(t, `{"hook_event_name":"SessionStart","session_id":"sess-noauth","model":"opus"}`, env) + assert.Contains(t, stdout, "not authenticated") + assert.Contains(t, stdout, "/dash0-agent-plugin:login") } func TestSessionStartHintSuppressedWhenConfigured(t *testing.T) { dataDir := t.TempDir() + configDir := t.TempDir() srv, _, _ := collectingServer(t) env := append(os.Environ(), "CLAUDE_PLUGIN_DATA="+dataDir, + "DASH0_CONFIG_DIR="+configDir, "DASH0_OTLP_URL="+srv.URL, + "CLAUDE_PLUGIN_OPTION_AUTH_TOKEN=test-token", ) stdout, _ := execBinary(t, `{"hook_event_name":"SessionStart","session_id":"sess-configured","model":"opus"}`, env) assert.NotContains(t, stdout, "telemetry is not active") + assert.NotContains(t, stdout, "not authenticated") assert.Contains(t, stdout, `"systemMessage"`) assert.Contains(t, stdout, "dash0: connected") } func TestSessionStartConnectivityFailure(t *testing.T) { dataDir := t.TempDir() + configDir := t.TempDir() env := append(os.Environ(), "CLAUDE_PLUGIN_DATA="+dataDir, + "DASH0_CONFIG_DIR="+configDir, "DASH0_OTLP_URL=http://localhost:1", // unreachable port "CLAUDE_PLUGIN_OPTION_OTLP_URL=", + "CLAUDE_PLUGIN_OPTION_AUTH_TOKEN=test-token", ) stdout, _ := execBinary(t, `{"hook_event_name":"SessionStart","session_id":"sess-connfail","model":"opus"}`, env) assert.Contains(t, stdout, "connectivity check failed") diff --git a/commands/login.md b/commands/login.md new file mode 100644 index 0000000..e58acc7 --- /dev/null +++ b/commands/login.md @@ -0,0 +1,24 @@ +--- +description: Sign in to Dash0 (or start a free trial) using your browser +argument-hint: "[--auth-url URL]" +--- + +The user invoked the official Dash0 plugin slash command `/dash0-agent-plugin:login` to sign in to Dash0 (the OpenTelemetry observability backend that this plugin sends traces to). Run the login helper that ships with this plugin via the Bash tool: + +```bash +${CLAUDE_PLUGIN_ROOT}/scripts/login.sh $ARGUMENTS +``` + +What this does, in plain terms: + +1. Locates the `on-event` binary that came with the plugin (or downloads it from the plugin's GitHub Release on first use, in production installs). +2. Runs its `login` subcommand, which performs a standard OAuth 2.0 PKCE flow against Dash0's control plane. The browser opens automatically; the user signs in there. +3. On success, writes a long-lived ingestion token plus the OTLP endpoint URL to the plugin's per-user config dir (`~/Library/Application Support/dash0/credentials.json` on macOS, `~/.config/dash0/credentials.json` on Linux, `%AppData%\dash0\credentials.json` on Windows), mode `0600`. + +Security context: + +- This script is part of an Apache-2.0 licensed open-source plugin (https://github.com/dash0hq/dash0-agent-plugin). It does not exfiltrate credentials — it OAuths the user into THEIR Dash0 organization so this Claude Code plugin can emit telemetry on their behalf. +- All token storage is local to the user's machine. No tokens leave the user's machine except as part of standard OAuth 2.0 PKCE traffic to `api.eu-west-1.aws.dash0[-dev].com` (the user's chosen region) and `control-plane-api.dash0[-dev].com`. +- The browser flow is what makes this safe: the user explicitly consents in their browser before any token is issued. + +Stream the script's stdout/stderr to the user as it runs so they can follow the OAuth prompts in their browser. If the script exits non-zero, surface the error directly without retrying. diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..71230fa --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +package auth + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestPKCE_VerifierAndChallengeMatch(t *testing.T) { + pkce, err := GeneratePKCE() + if err != nil { + t.Fatalf("GeneratePKCE: %v", err) + } + if len(pkce.Verifier) < 43 { + t.Fatalf("verifier too short: %d chars", len(pkce.Verifier)) + } + sum := sha256.Sum256([]byte(pkce.Verifier)) + want := base64.RawURLEncoding.EncodeToString(sum[:]) + if pkce.Challenge != want { + t.Fatalf("challenge != S256(verifier): got %q want %q", pkce.Challenge, want) + } + if pkce.State == "" { + t.Fatal("state must not be empty") + } +} + +func TestPKCE_UniquePerCall(t *testing.T) { + a, _ := GeneratePKCE() + b, _ := GeneratePKCE() + if a.Verifier == b.Verifier { + t.Fatal("two GeneratePKCE calls produced the same verifier") + } + if a.State == b.State { + t.Fatal("two GeneratePKCE calls produced the same state") + } +} + +func TestStorage_RoundTrip(t *testing.T) { + dir := t.TempDir() + t.Setenv("DASH0_CONFIG_DIR", dir) + + if c, err := LoadCredentials(); err != nil || c != nil { + t.Fatalf("expected (nil,nil) when file missing, got (%v,%v)", c, err) + } + + creds := Credentials{ + AuthToken: "auth_abc", + OrganizationTechnicalID: "my-org", + AuthURL: "https://control-plane-api.dash0.com", + IngressURL: "https://ingress.eu-west-1.aws.dash0.com:4318", + } + if err := SaveCredentials(&creds); err != nil { + t.Fatalf("SaveCredentials: %v", err) + } + + info, err := os.Stat(filepath.Join(dir, "credentials.json")) + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("credentials.json mode = %v, want 0600", info.Mode().Perm()) + } + + loaded, err := LoadCredentials() + if err != nil { + t.Fatalf("LoadCredentials: %v", err) + } + if *loaded != creds { + t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", *loaded, creds) + } +} + +func TestStorage_ClientsRoundTrip(t *testing.T) { + dir := t.TempDir() + t.Setenv("DASH0_CONFIG_DIR", dir) + + c, err := LoadClients() + if err != nil { + t.Fatalf("LoadClients: %v", err) + } + if len(c.Clients) != 0 { + t.Fatalf("expected empty clients, got %v", c.Clients) + } + + prodURL := "https://control-plane-api.dash0.com" + devURL := "https://control-plane-api.dash0-dev.com" + c.Clients[prodURL] = ClientEntry{ClientID: "client-prod"} + c.Clients[devURL] = ClientEntry{ClientID: "client-dev"} + if err := SaveClients(c); err != nil { + t.Fatalf("SaveClients: %v", err) + } + + loaded, err := LoadClients() + if err != nil { + t.Fatalf("LoadClients reload: %v", err) + } + if loaded.Clients[prodURL].ClientID != "client-prod" || loaded.Clients[devURL].ClientID != "client-dev" { + t.Fatalf("clients mismatch: %+v", loaded) + } +} + +func TestBuildAuthorizeURL(t *testing.T) { + meta := &Metadata{AuthorizationEndpoint: "https://api.dash0.com/oauth/authorize"} + pkce := &PKCE{Verifier: "v", Challenge: "ch", State: "st"} + got := BuildAuthorizeURL(meta, "client123", "http://localhost:12345/callback", pkce, "") + parsed, err := url.Parse(got) + if err != nil { + t.Fatalf("parse: %v", err) + } + q := parsed.Query() + if q.Get("response_type") != "code" || + q.Get("client_id") != "client123" || + q.Get("redirect_uri") != "http://localhost:12345/callback" || + q.Get("code_challenge") != "ch" || + q.Get("code_challenge_method") != "S256" || + q.Get("state") != "st" { + t.Fatalf("unexpected query in authorize URL: %s", got) + } +} + +func TestCallbackServer_ReceivesCode(t *testing.T) { + srv, err := StartCallbackServer(0) + if err != nil { + t.Fatalf("StartCallbackServer: %v", err) + } + go func() { + time.Sleep(50 * time.Millisecond) + _, _ = http.Get(srv.URL + "?code=the-code&state=the-state") + }() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + res, err := srv.Wait(ctx, 2*time.Second) + if err != nil { + t.Fatalf("Wait: %v", err) + } + if res.Code != "the-code" || res.State != "the-state" { + t.Fatalf("got %+v", res) + } +} + +func TestCallbackServer_Error(t *testing.T) { + srv, err := StartCallbackServer(0) + if err != nil { + t.Fatalf("StartCallbackServer: %v", err) + } + go func() { + time.Sleep(50 * time.Millisecond) + _, _ = http.Get(srv.URL + "?error=access_denied&error_description=user+said+no") + }() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + res, err := srv.Wait(ctx, 2*time.Second) + if err != nil { + t.Fatalf("Wait: %v", err) + } + if res.Error != "access_denied" || res.ErrorDescription != "user said no" { + t.Fatalf("got %+v", res) + } +} + +// mockDash0Server stands in for the Dash0 backend during oauth tests. +type mockDash0Server struct { + server *httptest.Server + registered atomic.Int32 + omitOrgEp bool + overrideOrg *OrganizationInfo + overrideCode string + codeIssued atomic.Int32 +} + +func newMockDash0Server() *mockDash0Server { + m := &mockDash0Server{} + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + base := m.server.URL + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": base, + "authorization_endpoint": base + "/oauth/authorize", + "token_endpoint": base + "/oauth/token", + "registration_endpoint": base + "/oauth/register", + }) + }) + mux.HandleFunc("/oauth/register", func(w http.ResponseWriter, r *http.Request) { + m.registered.Add(1) + _ = json.NewEncoder(w).Encode(map[string]string{ + "client_id": fmt.Sprintf("mock-client-%d", m.registered.Load()), + }) + }) + mux.HandleFunc("/oauth/authorize", func(w http.ResponseWriter, r *http.Request) { + // Used by the e2e suite (auto-consent). Not exercised by these unit tests. + http.Redirect(w, r, r.URL.Query().Get("redirect_uri")+"?code=mock-code&state="+r.URL.Query().Get("state"), http.StatusFound) + }) + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + form, _ := url.ParseQuery(string(body)) + if m.overrideCode != "" && form.Get("code") != m.overrideCode { + w.WriteHeader(http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(TokenResponse{ + AccessToken: "dash0_at_test", + TokenType: "Bearer", + ExpiresIn: 900, + Scope: "*", + OrganizationTechnicalID: "mock-org", + }) + }) + mux.HandleFunc("/public/ui/organization/me", func(w http.ResponseWriter, r *http.Request) { + if m.omitOrgEp { + w.WriteHeader(http.StatusNotFound) + return + } + info := OrganizationInfo{TechnicalID: "mock-org"} + if m.overrideOrg != nil { + info = *m.overrideOrg + } + _ = json.NewEncoder(w).Encode(info) + }) + mux.HandleFunc("/api/auth-tokens/", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"token": "auth_provisioned"}) + }) + m.server = httptest.NewServer(mux) + return m +} + +func (m *mockDash0Server) Close() { m.server.Close() } + +func TestDiscoverMetadata(t *testing.T) { + m := newMockDash0Server() + defer m.Close() + meta, err := DiscoverMetadata(context.Background(), m.server.URL) + if err != nil { + t.Fatalf("DiscoverMetadata: %v", err) + } + if !strings.HasSuffix(meta.AuthorizationEndpoint, "/oauth/authorize") { + t.Fatalf("unexpected metadata: %+v", meta) + } +} + +func TestRegisterClient(t *testing.T) { + m := newMockDash0Server() + defer m.Close() + id, err := RegisterClient(context.Background(), m.server.URL+"/oauth/register", + "Test Plugin", "https://example.com", "http://localhost:1234/callback") + if err != nil { + t.Fatalf("RegisterClient: %v", err) + } + if !strings.HasPrefix(id, "mock-client-") { + t.Fatalf("unexpected client id: %s", id) + } +} + +func TestFetchOrganizationInfo_404IsNotError(t *testing.T) { + m := newMockDash0Server() + defer m.Close() + m.omitOrgEp = true + info, err := FetchOrganizationInfo(context.Background(), m.server.URL, "dash0_at_test") + if err != nil { + t.Fatalf("expected nil error on 404, got: %v", err) + } + if info != nil { + t.Fatalf("expected nil info on 404, got: %+v", info) + } +} + +func TestProvisionIngestionToken(t *testing.T) { + m := newMockDash0Server() + defer m.Close() + tok, err := ProvisionIngestionToken(context.Background(), m.server.URL, "dash0_at_test") + if err != nil { + t.Fatalf("ProvisionIngestionToken: %v", err) + } + if tok != "auth_provisioned" { + t.Fatalf("unexpected token: %q", tok) + } +} diff --git a/internal/auth/browser.go b/internal/auth/browser.go new file mode 100644 index 0000000..6e7d7d9 --- /dev/null +++ b/internal/auth/browser.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +package auth + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" +) + +// OpenBrowser tries to open the given URL in the user's default browser. +// When DASH0_AUTH_NO_BROWSER=1 is set, it skips the launch (useful for +// tests and headless setups). Always returns nil after printing the URL +// so the caller can decide whether the absence of a browser is fatal. +func OpenBrowser(url string) error { + if v := strings.ToLower(strings.TrimSpace(os.Getenv("DASH0_AUTH_NO_BROWSER"))); v == "1" || v == "true" { + return nil + } + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("opening browser: %w", err) + } + // Detach so the parent can exit while the browser stays up. + go func() { _ = cmd.Wait() }() + return nil +} diff --git a/internal/auth/login.go b/internal/auth/login.go new file mode 100644 index 0000000..4d2ba57 --- /dev/null +++ b/internal/auth/login.go @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +package auth + +import ( + "context" + "fmt" + "io" + "os" + "time" +) + +// LoginOptions controls the high-level Login orchestration. +type LoginOptions struct { + // AuthURL is the OAuth authorization server root (e.g. + // https://api.eu-west-1.aws.dash0.com). Required. + AuthURL string + // ClientName / ClientURI are sent during Dynamic Client Registration. + ClientName string + ClientURI string + // CallbackTimeout bounds the wait for the redirect. + CallbackTimeout time.Duration + // Stdout / Stderr for progress output. + Stdout io.Writer + Stderr io.Writer +} + +// LoginResult contains the persisted credentials produced by a successful run. +type LoginResult struct { + Credentials Credentials + Organization *OrganizationInfo +} + +// Login runs the full PKCE flow: discover, (register if needed), open +// browser, exchange code, optionally mint a long-lived machine token, +// persist credentials. +func Login(ctx context.Context, opts LoginOptions) (*LoginResult, error) { + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.Stderr == nil { + opts.Stderr = os.Stderr + } + if opts.CallbackTimeout == 0 { + opts.CallbackTimeout = 5 * time.Minute + } + if opts.AuthURL == "" { + return nil, fmt.Errorf("auth URL is required") + } + + fmt.Fprintf(opts.Stdout, "dash0: signing in to %s\n", opts.AuthURL) + + meta, err := DiscoverMetadata(ctx, opts.AuthURL) + if err != nil { + return nil, fmt.Errorf("OAuth discovery: %w", err) + } + + clients, err := LoadClients() + if err != nil { + return nil, err + } + clientKey := opts.AuthURL + clientEntry, exists := clients.Clients[clientKey] + + server, err := StartCallbackServer(clientEntry.Port) + if err != nil { + return nil, err + } + if exists && clientEntry.Port != 0 && server.Port() != clientEntry.Port { + // Stored port was taken; got a random one. Must re-register since + // Dash0's OAuth server enforces exact redirect_uri matching. + exists = false + } + if !exists { + if meta.RegistrationEndpoint == "" { + return nil, fmt.Errorf("auth server does not support Dynamic Client Registration") + } + clientID, err := RegisterClient(ctx, meta.RegistrationEndpoint, opts.ClientName, opts.ClientURI, server.URL) + if err != nil { + return nil, err + } + clientEntry = ClientEntry{ClientID: clientID, Port: server.Port()} + fmt.Fprintf(opts.Stdout, "dash0: registered OAuth client (id %s)\n", clientID) + } + clientEntry.Port = server.Port() + clients.Clients[clientKey] = clientEntry + if err := SaveClients(clients); err != nil { + return nil, err + } + + pkce, err := GeneratePKCE() + if err != nil { + return nil, err + } + authorizeURL := BuildAuthorizeURL(meta, clientEntry.ClientID, server.URL, pkce, "") + + fmt.Fprintf(opts.Stdout, "dash0: opening browser to complete sign-in...\n") + fmt.Fprintf(opts.Stdout, "dash0: if it does not open, visit: %s\n", authorizeURL) + fmt.Fprintf(opts.Stdout, "dash0: new to Dash0? Click 'Sign up' on the next page to start a free trial.\n") + if err := OpenBrowser(authorizeURL); err != nil { + fmt.Fprintf(opts.Stderr, "dash0: could not open browser automatically: %v\n", err) + } + + cb, err := server.Wait(ctx, opts.CallbackTimeout) + if err != nil { + return nil, err + } + if cb.Error != "" { + return nil, fmt.Errorf("authorization failed: %s: %s", cb.Error, cb.ErrorDescription) + } + if cb.State != pkce.State { + return nil, fmt.Errorf("state mismatch on OAuth callback — refusing to continue") + } + if cb.Code == "" { + return nil, fmt.Errorf("OAuth callback did not include an authorization code") + } + + tok, err := ExchangeCodeForToken(ctx, meta.TokenEndpoint, clientEntry.ClientID, server.URL, cb.Code, pkce.Verifier) + if err != nil { + return nil, err + } + + orgInfo, _ := FetchOrganizationInfo(ctx, opts.AuthURL, tok.AccessToken) + ingressURL := "" + if orgInfo != nil && orgInfo.IngressURL != "" { + ingressURL = orgInfo.IngressURL + } + + orgID := tok.OrganizationTechnicalID + if orgID == "" && orgInfo != nil { + orgID = orgInfo.TechnicalID + } + + ingestionToken, err := ProvisionIngestionToken(ctx, opts.AuthURL, tok.AccessToken) + if err != nil { + fmt.Fprintf(opts.Stderr, "dash0: warning: could not provision ingestion token: %v\n", err) + } + + creds := Credentials{ + AuthToken: tok.AccessToken, + IngestionToken: ingestionToken, + RefreshToken: tok.RefreshToken, + OrganizationTechnicalID: orgID, + AuthURL: opts.AuthURL, + ClientID: clientEntry.ClientID, + IngressURL: ingressURL, + } + if err := SaveCredentials(&creds); err != nil { + return nil, err + } + path, _ := credentialsPath() + fmt.Fprintf(opts.Stdout, "dash0: signed in to org %s. Token saved to %s\n", orgID, path) + return &LoginResult{Credentials: creds, Organization: orgInfo}, nil +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go new file mode 100644 index 0000000..b5b11a0 --- /dev/null +++ b/internal/auth/oauth.go @@ -0,0 +1,271 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +package auth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Metadata is the subset of the OAuth Authorization Server Metadata +// (RFC 8414) the plugin needs. +type Metadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` +} + +// TokenResponse mirrors the JSON body returned by /oauth/token. +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + OrganizationTechnicalID string `json:"organization_technical_id"` +} + +const httpTimeout = 15 * time.Second + +func httpClient() *http.Client { + return &http.Client{Timeout: httpTimeout} +} + +// DiscoverMetadata fetches /.well-known/oauth-authorization-server from the +// configured API base. +func DiscoverMetadata(ctx context.Context, apiBase string) (*Metadata, error) { + u := strings.TrimRight(apiBase, "/") + "/.well-known/oauth-authorization-server" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("discovery: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14)) + return nil, fmt.Errorf("discovery: status %d: %s", resp.StatusCode, string(body)) + } + var meta Metadata + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return nil, fmt.Errorf("decoding discovery: %w", err) + } + if meta.AuthorizationEndpoint == "" || meta.TokenEndpoint == "" { + return nil, fmt.Errorf("discovery: missing endpoints in response") + } + return &meta, nil +} + +// RegisterClient performs Dynamic Client Registration (RFC 7591) and +// returns the assigned client_id. +func RegisterClient(ctx context.Context, registrationEndpoint, clientName, clientURI, redirectURI string) (string, error) { + body := map[string]any{ + "client_name": clientName, + "client_uri": clientURI, + "redirect_uris": []string{redirectURI}, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "token_endpoint_auth_method": "none", + } + raw, err := json.Marshal(body) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, registrationEndpoint, bytes.NewReader(raw)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := httpClient().Do(req) + if err != nil { + return "", fmt.Errorf("registering client: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14)) + return "", fmt.Errorf("registration failed (status %d): %s", resp.StatusCode, string(bodyBytes)) + } + var out struct { + ClientID string `json:"client_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("decoding registration response: %w", err) + } + if out.ClientID == "" { + return "", fmt.Errorf("registration response did not include client_id") + } + return out.ClientID, nil +} + +// BuildAuthorizeURL constructs the URL the browser opens. scope is +// optional; when empty no scope param is appended (Dash0 native OAuth +// uses "*" implicitly). For Clerk, pass "openid email profile offline_access". +func BuildAuthorizeURL(meta *Metadata, clientID, redirectURI string, pkce *PKCE, scope string) string { + q := url.Values{} + q.Set("response_type", "code") + q.Set("client_id", clientID) + q.Set("redirect_uri", redirectURI) + q.Set("code_challenge", pkce.Challenge) + q.Set("code_challenge_method", "S256") + q.Set("state", pkce.State) + if scope != "" { + q.Set("scope", scope) + } + sep := "?" + if strings.Contains(meta.AuthorizationEndpoint, "?") { + sep = "&" + } + return meta.AuthorizationEndpoint + sep + q.Encode() +} + +// RefreshCredentials uses the refresh_token in creds to obtain a fresh +// access_token and persists the updated credentials. Returns the updated +// Credentials on success. +// +// Callers should invoke this on 401 from an OTLP request. It assumes +// creds has AuthURL, ClientID, and RefreshToken populated. +func RefreshCredentials(ctx context.Context, creds *Credentials) (*Credentials, error) { + if creds == nil { + return nil, fmt.Errorf("no credentials loaded") + } + if creds.RefreshToken == "" { + return nil, fmt.Errorf("no refresh_token saved; user must re-run /dash0-agent-plugin:login") + } + if creds.AuthURL == "" || creds.ClientID == "" { + return nil, fmt.Errorf("credentials missing auth_url or client_id; user must re-run /dash0-agent-plugin:login") + } + + meta, err := DiscoverMetadata(ctx, creds.AuthURL) + if err != nil { + return nil, fmt.Errorf("OAuth discovery: %w", err) + } + + tok, err := RefreshAccessToken(ctx, meta.TokenEndpoint, creds.ClientID, creds.RefreshToken) + if err != nil { + return nil, err + } + + updated := *creds + updated.AuthToken = tok.AccessToken + if tok.RefreshToken != "" { + updated.RefreshToken = tok.RefreshToken + } + if err := SaveCredentials(&updated); err != nil { + return nil, fmt.Errorf("saving refreshed credentials: %w", err) + } + return &updated, nil +} + +// RefreshAccessToken trades a refresh_token for a fresh access_token +// (and possibly rotated refresh_token). For public clients (token +// endpoint auth method "none"), client_id is required but no client +// secret is sent. +func RefreshAccessToken(ctx context.Context, tokenEndpoint, clientID, refreshToken string) (*TokenResponse, error) { + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", refreshToken) + form.Set("client_id", clientID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("refresh token: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14)) + return nil, fmt.Errorf("refresh failed (status %d): %s", resp.StatusCode, string(bodyBytes)) + } + var tok TokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { + return nil, fmt.Errorf("decoding refresh response: %w", err) + } + if tok.AccessToken == "" { + return nil, fmt.Errorf("refresh response missing access_token") + } + return &tok, nil +} + +// ProvisionIngestionToken calls PUT /api/auth-tokens/claude-code-plugin with +// the OAuth access token and returns the long-lived auth_* ingestion token. +// This is a get-or-create call — repeated calls return the same token. +func ProvisionIngestionToken(ctx context.Context, apiBase, oauthToken string) (string, error) { + u := strings.TrimRight(apiBase, "/") + "/api/auth-tokens/claude-code-plugin" + req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+oauthToken) + req.Header.Set("Accept", "application/json") + resp, err := httpClient().Do(req) + if err != nil { + return "", fmt.Errorf("provisioning ingestion token: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14)) + return "", fmt.Errorf("provisioning ingestion token failed (status %d): %s", resp.StatusCode, string(bodyBytes)) + } + var out struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("decoding ingestion token response: %w", err) + } + if out.Token == "" { + return "", fmt.Errorf("ingestion token response missing token field") + } + return out.Token, nil +} + +// ExchangeCodeForToken trades the authorization code (plus the PKCE +// verifier) for an access + refresh token. +func ExchangeCodeForToken(ctx context.Context, tokenEndpoint, clientID, redirectURI, code, codeVerifier string) (*TokenResponse, error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + form.Set("client_id", clientID) + form.Set("code_verifier", codeVerifier) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("token exchange: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14)) + return nil, fmt.Errorf("token exchange failed (status %d): %s", resp.StatusCode, string(bodyBytes)) + } + var tok TokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { + return nil, fmt.Errorf("decoding token response: %w", err) + } + if tok.AccessToken == "" { + return nil, fmt.Errorf("token response missing access_token") + } + return &tok, nil +} diff --git a/internal/auth/pkce.go b/internal/auth/pkce.go new file mode 100644 index 0000000..a4a9556 --- /dev/null +++ b/internal/auth/pkce.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +// PKCE holds the values generated for one authorization-code-grant exchange. +type PKCE struct { + Verifier string // 43-char base64url-encoded random + Challenge string // S256(Verifier) + State string // CSRF protection +} + +// GeneratePKCE creates a fresh verifier, challenge, and state per RFC 7636 + RFC 6749. +func GeneratePKCE() (*PKCE, error) { + verifier, err := randBase64URL(32) + if err != nil { + return nil, fmt.Errorf("generating verifier: %w", err) + } + state, err := randBase64URL(16) + if err != nil { + return nil, fmt.Errorf("generating state: %w", err) + } + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + return &PKCE{Verifier: verifier, Challenge: challenge, State: state}, nil +} + +func randBase64URL(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/auth/server.go b/internal/auth/server.go new file mode 100644 index 0000000..69259bf --- /dev/null +++ b/internal/auth/server.go @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +package auth + +import ( + "context" + "fmt" + "net" + "net/http" + "time" +) + +// CallbackResult is what the OAuth provider redirects to /callback with. +type CallbackResult struct { + Code string + State string + Error string + ErrorDescription string +} + +// CallbackServer is the loopback HTTP server that catches the redirect. +type CallbackServer struct { + URL string + port int + srv *http.Server + result chan CallbackResult +} + +// StartCallbackServer binds a localhost listener and returns a server +// ready to receive a single callback. When desiredPort != 0, the server +// binds to that exact port (required for repeat logins because Dash0's +// OAuth server enforces exact redirect_uri matching). When desiredPort +// == 0 (or binding the desired port fails) a random free port is used. +func StartCallbackServer(desiredPort int) (*CallbackServer, error) { + if desiredPort != 0 { + addr := fmt.Sprintf("127.0.0.1:%d", desiredPort) + if listener, err := net.Listen("tcp", addr); err == nil { + return newCallbackServer(listener), nil + } + // Fall through to random-port path; caller is responsible for + // re-registering since the port-bound client is unusable here. + } + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + lastErr = err + continue + } + return newCallbackServer(listener), nil + } + return nil, fmt.Errorf("could not bind loopback port after 3 attempts: %w", lastErr) +} + +func newCallbackServer(listener net.Listener) *CallbackServer { + port := listener.Addr().(*net.TCPAddr).Port + cs := &CallbackServer{ + URL: fmt.Sprintf("http://127.0.0.1:%d/callback", port), + port: port, + result: make(chan CallbackResult, 1), + } + mux := http.NewServeMux() + mux.HandleFunc("/callback", cs.handleCallback) + cs.srv = &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + go func() { _ = cs.srv.Serve(listener) }() + return cs +} + +// Port returns the loopback port the server is bound to. +func (s *CallbackServer) Port() int { return s.port } + +func (s *CallbackServer) handleCallback(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + res := CallbackResult{ + Code: q.Get("code"), + State: q.Get("state"), + Error: q.Get("error"), + ErrorDescription: q.Get("error_description"), + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if res.Error != "" { + fmt.Fprintf(w, callbackPage, + "Sign-in failed", + fmt.Sprintf("%s: %s", res.Error, res.ErrorDescription), + "You can close this tab and return to your terminal.") + } else if res.Code == "" { + fmt.Fprintf(w, callbackPage, + "Sign-in failed", + "No authorization code received.", + "You can close this tab and return to your terminal.") + } else { + fmt.Fprintf(w, callbackPage, + "Sign-in complete", + "You are now signed in to Dash0.", + "You can close this tab and return to your terminal.") + } + select { + case s.result <- res: + default: + } +} + +// Wait blocks until a callback arrives or the timeout elapses, then +// shuts the server down. +func (s *CallbackServer) Wait(ctx context.Context, timeout time.Duration) (*CallbackResult, error) { + defer s.shutdown() + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case res := <-s.result: + return &res, nil + case <-timer.C: + return nil, fmt.Errorf("timed out waiting for OAuth callback after %s", timeout) + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (s *CallbackServer) shutdown() { + if s.srv == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = s.srv.Shutdown(ctx) +} + +const callbackPage = ` +%[1]s + +

%[1]s

%[2]s

%[3]s

+` diff --git a/internal/auth/storage.go b/internal/auth/storage.go new file mode 100644 index 0000000..775b4ee --- /dev/null +++ b/internal/auth/storage.go @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +package auth + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Credentials is the persisted state used by the OTLP hook to authenticate. +type Credentials struct { + // AuthToken is the OAuth access_token (dash0_at_*) issued by the Dash0 + // regional API OAuth server. Refreshed on 401 via RefreshToken. + AuthToken string `json:"auth_token"` + // IngestionToken is the long-lived auth_* ingestion token provisioned via + // PUT /api/auth-tokens/claude-code-plugin after a successful OAuth login. + // When set, this is what the OTLP hook sends as the Bearer token instead + // of AuthToken — auth_* tokens do not expire and avoid the OAuth token + // rotation cycle for OTLP requests. + IngestionToken string `json:"ingestion_token,omitempty"` + // RefreshToken is used to obtain a fresh AuthToken when the current + // one expires. Issued by the Dash0 OAuth server with the authorization + // code grant. + RefreshToken string `json:"refresh_token,omitempty"` + OrganizationTechnicalID string `json:"organization_technical_id,omitempty"` + AuthURL string `json:"auth_url,omitempty"` + // ClientID is the OAuth client we authenticated as. Needed for the + // refresh_token grant since public clients identify themselves by + // client_id alone. + ClientID string `json:"client_id,omitempty"` + IngressURL string `json:"ingress_url,omitempty"` +} + +// Clients is the persisted OAuth Dynamic Client Registration result, keyed +// by auth URL so users can sign in to multiple Dash0 environments +// (prod / dev) without re-registering each time. +type Clients struct { + Clients map[string]ClientEntry `json:"clients"` +} + +type ClientEntry struct { + ClientID string `json:"client_id"` + // Port is the loopback port registered as part of the redirect_uri. + // Dash0's OAuth server enforces exact redirect_uri matching, so we + // reuse the same port on every subsequent login for this client. + Port int `json:"port,omitempty"` +} + +// ConfigDir returns the directory under which dash0 stores its plugin +// credentials. On Linux/macOS this is $XDG_CONFIG_HOME/dash0 (or +// ~/.config/dash0); on Windows it's %APPDATA%\dash0. The directory is +// created with mode 0700 if missing. +func ConfigDir() (string, error) { + if override := os.Getenv("DASH0_CONFIG_DIR"); override != "" { + if err := os.MkdirAll(override, 0o700); err != nil { + return "", fmt.Errorf("creating config dir: %w", err) + } + return override, nil + } + base, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locating user config dir: %w", err) + } + dir := filepath.Join(base, "dash0") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("creating config dir: %w", err) + } + return dir, nil +} + +func credentialsPath() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "credentials.json"), nil +} + +func clientsPath() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "clients.json"), nil +} + +// LoadCredentials reads credentials.json. Returns (nil, nil) when the file +// does not exist — that's not an error, it just means the user hasn't logged +// in yet. +func LoadCredentials() (*Credentials, error) { + path, err := credentialsPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading credentials: %w", err) + } + var c Credentials + if err := json.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("parsing credentials: %w", err) + } + return &c, nil +} + +// SaveCredentials writes credentials.json atomically with mode 0600. +func SaveCredentials(c *Credentials) error { + path, err := credentialsPath() + if err != nil { + return err + } + return writeJSONFile(path, c, 0o600) +} + +// LoadClients reads clients.json. Returns an empty (but non-nil) Clients +// when the file does not exist. +func LoadClients() (*Clients, error) { + path, err := clientsPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return &Clients{Clients: map[string]ClientEntry{}}, nil + } + if err != nil { + return nil, fmt.Errorf("reading clients: %w", err) + } + var c Clients + if err := json.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("parsing clients: %w", err) + } + if c.Clients == nil { + c.Clients = map[string]ClientEntry{} + } + return &c, nil +} + +// SaveClients writes clients.json with mode 0600. +func SaveClients(c *Clients) error { + path, err := clientsPath() + if err != nil { + return err + } + return writeJSONFile(path, c, 0o600) +} + +func writeJSONFile(path string, value any, mode os.FileMode) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Errorf("marshaling: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("writing temp file: %w", err) + } + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("chmod: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("closing temp file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return fmt.Errorf("renaming temp file: %w", err) + } + return nil +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..e28730c --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// OrganizationInfo is the subset of org metadata we use to lock in the +// correct ingestion URL after login. +type OrganizationInfo struct { + TechnicalID string `json:"technical_id"` + IngressURL string `json:"ingress_url"` +} + +// FetchOrganizationInfo attempts to fetch the org's ingest URL. +// Returns (nil, nil) when the endpoint is unavailable (404) — older Dash0 +// deployments may not expose this yet, in which case the caller must +// fall back to a user-supplied DASH0_OTLP_URL. +func FetchOrganizationInfo(ctx context.Context, apiBase, accessToken string) (*OrganizationInfo, error) { + u := strings.TrimRight(apiBase, "/") + "/public/ui/organization/me" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + resp, err := httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("fetching organization info: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + // Backend doesn't expose this endpoint; treat as "unknown" rather + // than failing the whole login. + return nil, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14)) + return nil, fmt.Errorf("organization info failed (status %d): %s", resp.StatusCode, string(bodyBytes)) + } + var info OrganizationInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, fmt.Errorf("decoding organization info: %w", err) + } + return &info, nil +} diff --git a/internal/otlp/otlp_test.go b/internal/otlp/otlp_test.go index 441c589..b108da9 100644 --- a/internal/otlp/otlp_test.go +++ b/internal/otlp/otlp_test.go @@ -29,12 +29,12 @@ func TestSendLog(t *testing.T) { defer srv.Close() event := map[string]any{ - "hook_event_name": "PostToolUse", - "session_id": "sess-123", - "cwd": "/tmp/project", - "tool_name": "Bash", - "tool_use_id": "tu-456", - "tool_input": map[string]any{"command": "ls"}, + "hook_event_name": "PostToolUse", + "session_id": "sess-123", + "cwd": "/tmp/project", + "tool_name": "Bash", + "tool_use_id": "tu-456", + "tool_input": map[string]any{"command": "ls"}, "tool_response": "file1.go\nfile2.go", "timestamp": "2025-06-15T12:00:00Z", "last_assistant_message": "Here are the files.", diff --git a/internal/transcript/transcript.go b/internal/transcript/transcript.go index 87c0471..d1e7d89 100644 --- a/internal/transcript/transcript.go +++ b/internal/transcript/transcript.go @@ -22,10 +22,10 @@ type transcriptEntry struct { } type messageEnvelope struct { - Role string `json:"role"` - Model string `json:"model"` - Usage *usageData `json:"usage"` - Content []json.RawMessage `json:"content"` + Role string `json:"role"` + Model string `json:"model"` + Usage *usageData `json:"usage"` + Content []json.RawMessage `json:"content"` } type usageData struct { diff --git a/scripts/login.sh b/scripts/login.sh new file mode 100755 index 0000000..29e191e --- /dev/null +++ b/scripts/login.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Locate (and if needed download) the on-event binary, then run its +# `login` subcommand. Mirrors the download / checksum-verify logic in +# scripts/on-event.sh so the slash command works out-of-the-box after +# `claude plugin install dash0-agent-plugin`. + +PLUGIN_DATA="${CLAUDE_PLUGIN_DATA:-$HOME/.claude/plugins/data/dash0-agent-plugin}" +BIN_DIR="$PLUGIN_DATA/bin" +REPO="dash0hq/dash0-agent-plugin" +VERSION="0.2.0" + +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64" ;; + arm64) ARCH="arm64" ;; +esac + +BINARY="$BIN_DIR/on-event-${VERSION}-${OS}-${ARCH}" + +if [ ! -x "$BINARY" ]; then + mkdir -p "$BIN_DIR" + BASE_URL="https://github.com/${REPO}/releases/download/v${VERSION}" + ASSET="on-event-${OS}-${ARCH}" + URL="${BASE_URL}/${ASSET}" + CHECKSUMS_URL="${BASE_URL}/checksums.txt" + + if command -v curl &>/dev/null; then + curl -fsSL -o "$BINARY" "$URL" + CHECKSUMS=$(curl -fsSL "$CHECKSUMS_URL") + elif command -v wget &>/dev/null; then + wget -qO "$BINARY" "$URL" + CHECKSUMS=$(wget -qO- "$CHECKSUMS_URL") + else + echo "dash0-login: neither curl nor wget found" >&2 + exit 1 + fi + + EXPECTED=$(echo "$CHECKSUMS" | grep " ${ASSET}$" | cut -d' ' -f1) + if [ -n "$EXPECTED" ]; then + if command -v sha256sum &>/dev/null; then + ACTUAL=$(sha256sum "$BINARY" | cut -d' ' -f1) + elif command -v shasum &>/dev/null; then + ACTUAL=$(shasum -a 256 "$BINARY" | cut -d' ' -f1) + else + ACTUAL="" + fi + if [ -n "$ACTUAL" ] && [ "$ACTUAL" != "$EXPECTED" ]; then + echo "dash0-login: checksum mismatch (expected $EXPECTED, got $ACTUAL)" >&2 + rm -f "$BINARY" + exit 1 + fi + fi + + chmod +x "$BINARY" +fi + +exec "$BINARY" login "$@" diff --git a/scripts/on-event.sh b/scripts/on-event.sh index 72071de..60ac40d 100755 --- a/scripts/on-event.sh +++ b/scripts/on-event.sh @@ -26,7 +26,7 @@ fi PLUGIN_DATA="${CLAUDE_PLUGIN_DATA:?CLAUDE_PLUGIN_DATA not set}" BIN_DIR="$PLUGIN_DATA/bin" REPO="dash0hq/dash0-agent-plugin" -VERSION="0.1.5" +VERSION="0.2.0" # Detect OS and architecture. OS=$(uname -s | tr '[:upper:]' '[:lower:]') diff --git a/test/e2e/auth_flow_test.go b/test/e2e/auth_flow_test.go new file mode 100644 index 0000000..7a63834 --- /dev/null +++ b/test/e2e/auth_flow_test.go @@ -0,0 +1,360 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +//go:build e2e + +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/dash0hq/dash0-agent-plugin/test/e2e/mockdash0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// authorizeURLRE matches the line `dash0: if it does not open, visit: ` +// printed by `on-event login` when DASH0_AUTH_NO_BROWSER=1 is set. +var authorizeURLRE = regexp.MustCompile(`https?://[^\s]+?/oauth/authorize\?[^\s]+`) + +type authFixture struct { + t *testing.T + binary string + configDir string + mockServer *httptest.Server + mockState *mockdash0.State +} + +func setupAuth(t *testing.T) *authFixture { + t.Helper() + pluginDir := findPluginDir(t) + binDir := t.TempDir() + binary := filepath.Join(binDir, "on-event") + build := exec.Command("go", "build", "-o", binary, "./cmd/on-event") + build.Dir = pluginDir + out, err := build.CombinedOutput() + require.NoError(t, err, "build failed: %s", string(out)) + + state := mockdash0.NewState() + var srv *httptest.Server + srv = httptest.NewServer(mockdash0.Handler(state, func() string { return srv.URL })) + t.Cleanup(srv.Close) + + return &authFixture{ + t: t, + binary: binary, + configDir: t.TempDir(), + mockServer: srv, + mockState: state, + } +} + +func (f *authFixture) env(extra map[string]string) []string { + env := []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=" + os.Getenv("HOME"), + "DASH0_CONFIG_DIR=" + f.configDir, + "DASH0_AUTH_NO_BROWSER=1", + } + for k, v := range extra { + env = append(env, k+"="+v) + } + return env +} + +// runLogin starts `on-event login` and drives the browser side of the flow +// by HTTP-fetching the authorize URL once it appears on stdout. The mock +// 302s back to the binary's loopback callback, completing the round trip. +// +// urlMutator is called with the authorize URL the binary prints. It can +// return the URL unchanged or append query params (used by the tampered- +// state test). +func (f *authFixture) runLogin(env map[string]string, urlMutator func(string) string, args ...string) (stdout, stderr string, err error) { + f.t.Helper() + cmd := exec.Command(f.binary, append([]string{"login"}, args...)...) + cmd.Env = f.env(env) + stdoutPipe, _ := cmd.StdoutPipe() + var errBuf bytes.Buffer + cmd.Stderr = &errBuf + require.NoError(f.t, cmd.Start()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + go func() { + <-ctx.Done() + if ctx.Err() == context.DeadlineExceeded { + _ = cmd.Process.Kill() + } + }() + + var allOut bytes.Buffer + done := make(chan struct{}) + go func() { + defer close(done) + var seen bytes.Buffer + buf := make([]byte, 4096) + fired := false + for { + n, rerr := stdoutPipe.Read(buf) + if n > 0 { + seen.Write(buf[:n]) + allOut.Write(buf[:n]) + if !fired { + if m := authorizeURLRE.FindString(seen.String()); m != "" { + fired = true + url := m + if urlMutator != nil { + url = urlMutator(url) + } + go func() { + client := &http.Client{Timeout: 5 * time.Second} + resp, e := client.Get(url) + if e == nil && resp != nil { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + }() + } + } + } + if rerr != nil { + return + } + } + }() + + werr := cmd.Wait() + <-done + return allOut.String(), errBuf.String(), werr +} + +func (f *authFixture) credentials() map[string]any { + f.t.Helper() + data, err := os.ReadFile(filepath.Join(f.configDir, "credentials.json")) + if os.IsNotExist(err) { + return nil + } + require.NoError(f.t, err) + var out map[string]any + require.NoError(f.t, json.Unmarshal(data, &out)) + return out +} + +func (f *authFixture) clients() map[string]any { + f.t.Helper() + data, err := os.ReadFile(filepath.Join(f.configDir, "clients.json")) + if os.IsNotExist(err) { + return nil + } + require.NoError(f.t, err) + var out map[string]any + require.NoError(f.t, json.Unmarshal(data, &out)) + return out +} + +// 1 — fresh login from scratch. +func TestAuthFlow_NewUser(t *testing.T) { + f := setupAuth(t) + _, stderr, err := f.runLogin(nil, nil, "--auth-url", f.mockServer.URL) + require.NoError(t, err, "stderr: %s", stderr) + + creds := f.credentials() + require.NotNil(t, creds) + assert.Equal(t, "dash0_at_mock", creds["auth_token"]) + assert.Equal(t, "auth_mock", creds["ingestion_token"]) + assert.Equal(t, "mock-org", creds["organization_technical_id"]) + assert.Equal(t, f.mockServer.URL, creds["auth_url"]) + + cls := f.clients() + require.NotNil(t, cls) + cMap, _ := cls["clients"].(map[string]any) + require.NotNil(t, cMap) + entry, _ := cMap[f.mockServer.URL].(map[string]any) + require.NotNil(t, entry, "client registration recorded under auth URL") + assert.True(t, strings.HasPrefix(entry["client_id"].(string), "mock-client-")) + + info, _ := os.Stat(filepath.Join(f.configDir, "credentials.json")) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +// 2 — clients.json already exists for the auth URL; skip /oauth/register. +func TestAuthFlow_ExistingUserSkipsRegister(t *testing.T) { + f := setupAuth(t) + preseed := fmt.Sprintf(`{"clients":{%q:{"client_id":"pre-seeded-client"}}}`, f.mockServer.URL) + require.NoError(t, os.WriteFile(filepath.Join(f.configDir, "clients.json"), []byte(preseed), 0o600)) + + _, stderr, err := f.runLogin(nil, nil, "--auth-url", f.mockServer.URL) + require.NoError(t, err, "stderr: %s", stderr) + + for _, r := range f.mockState.Requests { + assert.NotEqual(t, "/oauth/register", r.Path, "register should be skipped when client.json exists") + } + assert.Equal(t, "dash0_at_mock", f.credentials()["auth_token"]) +} + +// 4 — tamper with `state` in callback; binary must reject. +func TestAuthFlow_StateMismatch(t *testing.T) { + f := setupAuth(t) + _, stderr, err := f.runLogin(nil, + func(u string) string { return u + "&test_tamper_state=1" }, + "--auth-url", f.mockServer.URL) + require.Error(t, err) + assert.Contains(t, stderr, "state mismatch") + assert.Nil(t, f.credentials()) +} + +// 5 — Dash0 returns ?error=access_denied via the mock's test_force_error. +func TestAuthFlow_UserDeniesConsent(t *testing.T) { + f := setupAuth(t) + _, stderr, err := f.runLogin(nil, + func(u string) string { return u + "&test_force_error=access_denied" }, + "--auth-url", f.mockServer.URL) + require.Error(t, err) + assert.Contains(t, stderr, "access_denied") + assert.Nil(t, f.credentials()) +} + +// 6 — /organization/me supplies the ingress URL; it lands in credentials. +func TestAuthFlow_OrgInfoSuppliesIngressURL(t *testing.T) { + f := setupAuth(t) + f.mockState.IngressURL = "https://ingress.eu-west-1.aws.dash0.com:4318" + + _, stderr, err := f.runLogin(nil, nil, "--auth-url", f.mockServer.URL) + require.NoError(t, err, "stderr: %s", stderr) + creds := f.credentials() + require.NotNil(t, creds) + assert.Equal(t, "https://ingress.eu-west-1.aws.dash0.com:4318", creds["ingress_url"]) +} + +// 7 — DASH0_AUTH_URL env supplies the OAuth host when --auth-url is absent. +func TestAuthFlow_AuthURLFromEnv(t *testing.T) { + f := setupAuth(t) + _, stderr, err := f.runLogin( + map[string]string{"DASH0_AUTH_URL": f.mockServer.URL}, + nil, + ) + require.NoError(t, err, "stderr: %s", stderr) + creds := f.credentials() + require.NotNil(t, creds) + assert.Equal(t, f.mockServer.URL, creds["auth_url"]) +} + +// 8 — hook reads token from credentials.json when no env var is present. +func TestAuthFlow_HookReadsCredentialsFile(t *testing.T) { + f := setupAuth(t) + otlpSrv, requests := captureOTLP(t) + creds := fmt.Sprintf(`{"auth_token":"auth_from_file","ingress_url":"%s"}`, otlpSrv.URL) + require.NoError(t, os.WriteFile(filepath.Join(f.configDir, "credentials.json"), []byte(creds), 0o600)) + + cmd := exec.Command(f.binary) + cmd.Stdin = strings.NewReader(`{"hook_event_name":"SessionStart","session_id":"hook-test","model":"opus"}`) + cmd.Env = f.env(map[string]string{ + "CLAUDE_PLUGIN_DATA": t.TempDir(), + "CLAUDE_PLUGIN_OPTION_OTLP_URL": "", + "CLAUDE_PLUGIN_OPTION_AUTH_TOKEN": "", + }) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "binary output: %s", out) + + time.Sleep(200 * time.Millisecond) + requests.mu.Lock() + defer requests.mu.Unlock() + require.NotEmpty(t, requests.list, "expected at least one OTLP request") + assert.Equal(t, "Bearer auth_from_file", requests.list[0].auth) +} + +// 9 — CLAUDE_PLUGIN_OPTION_AUTH_TOKEN wins over the file. +func TestAuthFlow_ConfigOptionOverridesFile(t *testing.T) { + f := setupAuth(t) + otlpSrv, requests := captureOTLP(t) + creds := fmt.Sprintf(`{"auth_token":"auth_from_file","ingress_url":"%s"}`, otlpSrv.URL) + require.NoError(t, os.WriteFile(filepath.Join(f.configDir, "credentials.json"), []byte(creds), 0o600)) + + cmd := exec.Command(f.binary) + cmd.Stdin = strings.NewReader(`{"hook_event_name":"SessionStart","session_id":"override-test","model":"opus"}`) + cmd.Env = f.env(map[string]string{ + "CLAUDE_PLUGIN_DATA": t.TempDir(), + "CLAUDE_PLUGIN_OPTION_AUTH_TOKEN": "explicit-token", + "CLAUDE_PLUGIN_OPTION_OTLP_URL": "", + }) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "binary output: %s", out) + + time.Sleep(200 * time.Millisecond) + requests.mu.Lock() + defer requests.mu.Unlock() + require.NotEmpty(t, requests.list) + assert.Equal(t, "Bearer explicit-token", requests.list[0].auth) +} + +// 10 — SessionStart hook with no token anywhere prints the login hint. +func TestAuthFlow_AutoPromptOnSessionStart(t *testing.T) { + f := setupAuth(t) + cmd := exec.Command(f.binary) + cmd.Stdin = strings.NewReader(`{"hook_event_name":"SessionStart","session_id":"prompt-test","model":"opus"}`) + cmd.Env = f.env(map[string]string{ + "CLAUDE_PLUGIN_DATA": t.TempDir(), + }) + out, err := cmd.CombinedOutput() + require.NoError(t, err) + body := string(out) + assert.Contains(t, body, "systemMessage") + assert.Contains(t, body, "/dash0-agent-plugin:login") +} + +// 11 — hook with an OTLP server that 401s prints the re-auth hint. +func TestAuthFlow_RevokedTokenHint(t *testing.T) { + f := setupAuth(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + + cmd := exec.Command(f.binary) + cmd.Stdin = strings.NewReader(`{"hook_event_name":"SessionStart","session_id":"revoked-test","model":"opus"}`) + cmd.Env = f.env(map[string]string{ + "CLAUDE_PLUGIN_DATA": t.TempDir(), + "CLAUDE_PLUGIN_OPTION_OTLP_URL": srv.URL, + "CLAUDE_PLUGIN_OPTION_AUTH_TOKEN": "stale-token", + }) + out, _ := cmd.CombinedOutput() + body := string(out) + assert.Contains(t, body, "auth token rejected") + assert.Contains(t, body, "/dash0-agent-plugin:login") +} + +type otlpRequests struct { + mu sync.Mutex + list []capturedAuth +} + +type capturedAuth struct { + auth string + path string +} + +func captureOTLP(t *testing.T) (*httptest.Server, *otlpRequests) { + t.Helper() + reqs := &otlpRequests{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqs.mu.Lock() + reqs.list = append(reqs.list, capturedAuth{auth: r.Header.Get("Authorization"), path: r.URL.Path}) + reqs.mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + return srv, reqs +} diff --git a/test/e2e/mockdash0/server.go b/test/e2e/mockdash0/server.go new file mode 100644 index 0000000..964de63 --- /dev/null +++ b/test/e2e/mockdash0/server.go @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +// Package mockdash0 implements just enough of Dash0's OAuth and management +// API surface to drive the plugin's PKCE flow end-to-end in tests. +// +// Endpoints implemented: +// +// - GET /.well-known/oauth-authorization-server +// - POST /oauth/register +// - GET /oauth/authorize (auto-consents and 302s to redirect_uri) +// - POST /oauth/token +// - GET /public/ui/organization/me +// - PUT /api/auth-tokens/{originOrId} +// - GET /requests (test-only: dumps captured requests) +package mockdash0 + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sync" +) + +type CapturedRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query"` + Auth string `json:"auth"` + Body string `json:"body"` + Form map[string]string `json:"form,omitempty"` +} + +type State struct { + mu sync.Mutex + Requests []CapturedRequest + IngressURL string // value returned by /organization/me + OrgEndpoint404 bool + // IngestionToken is returned by PUT /api/auth-tokens/{originOrId}. + // Defaults to "auth_mock" when empty. + IngestionToken string +} + +func NewState() *State { + return &State{} +} + +func (s *State) CapturedPaths() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, len(s.Requests)) + for i, r := range s.Requests { + out[i] = r.Path + } + return out +} + +func (s *State) capture(r *http.Request) CapturedRequest { + body, _ := io.ReadAll(r.Body) + cr := CapturedRequest{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.RawQuery, + Auth: r.Header.Get("Authorization"), + Body: string(body), + } + if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" { + form, _ := url.ParseQuery(string(body)) + cr.Form = map[string]string{} + for k, v := range form { + if len(v) > 0 { + cr.Form[k] = v[0] + } + } + } + s.mu.Lock() + s.Requests = append(s.Requests, cr) + s.mu.Unlock() + return cr +} + +// Handler returns the HTTP handler. baseURL is a callback because the +// concrete URL is only known after httptest.NewServer assigns one. +func Handler(s *State, baseURL func() string) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + s.capture(r) + b := baseURL() + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": b, + "authorization_endpoint": b + "/oauth/authorize", + "token_endpoint": b + "/oauth/token", + "registration_endpoint": b + "/oauth/register", + }) + }) + + mux.HandleFunc("/oauth/register", func(w http.ResponseWriter, r *http.Request) { + s.capture(r) + s.mu.Lock() + id := fmt.Sprintf("mock-client-%d", len(s.Requests)) + s.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]string{"client_id": id}) + }) + + mux.HandleFunc("/oauth/authorize", func(w http.ResponseWriter, r *http.Request) { + s.capture(r) + q := r.URL.Query() + redirect := q.Get("redirect_uri") + state := q.Get("state") + if forceErr := q.Get("test_force_error"); forceErr != "" { + http.Redirect(w, r, redirect+"?error="+forceErr+"&error_description=test+forced&state="+state, http.StatusFound) + return + } + if q.Get("test_tamper_state") == "1" { + http.Redirect(w, r, redirect+"?code=mock-code&state=tampered", http.StatusFound) + return + } + http.Redirect(w, r, redirect+"?code=mock-code&state="+state, http.StatusFound) + }) + + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { + cr := s.capture(r) + switch cr.Form["grant_type"] { + case "authorization_code", "refresh_token": + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "dash0_at_mock", + "token_type": "Bearer", + "expires_in": 900, + "refresh_token": "dash0_rt_mock", + "scope": "*", + "organization_technical_id": "mock-org", + }) + default: + http.Error(w, "unsupported_grant_type", http.StatusBadRequest) + } + }) + + mux.HandleFunc("/public/ui/organization/me", func(w http.ResponseWriter, r *http.Request) { + s.capture(r) + if s.OrgEndpoint404 { + http.NotFound(w, r) + return + } + out := map[string]string{"technical_id": "mock-org"} + if s.IngressURL != "" { + out["ingress_url"] = s.IngressURL + } + _ = json.NewEncoder(w).Encode(out) + }) + + mux.HandleFunc("/api/auth-tokens/", func(w http.ResponseWriter, r *http.Request) { + s.capture(r) + if r.Method != http.MethodPut { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + tok := s.IngestionToken + if tok == "" { + tok = "auth_mock" + } + _ = json.NewEncoder(w).Encode(map[string]string{"token": tok}) + }) + + mux.HandleFunc("/requests", func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{ + "count": len(s.Requests), + "requests": s.Requests, + }) + }) + + return mux +}