From 3b5fd99675c0fc69a76ad5bdfcb30b2186c08a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Conrad=20P=C3=B6pke?= Date: Mon, 18 May 2026 00:39:08 +0200 Subject: [PATCH 1/3] feat(auth): Clerk OAuth login flow + refresh tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a browser-based sign-in flow so users can authenticate the Dash0 Claude Code plugin once and have it emit telemetry on their behalf. - /dash0-agent-plugin:login slash command runs OAuth 2.0 + PKCE against Clerk (Dash0's identity provider). Discovery via RFC 8414 metadata, PKCE S256. Defaults to https://clerk.dash0.com; auto-selects the dev tenant when DASH0_OTLP_URL points at a .dash0-dev.com host. - Credentials are persisted to the OS user-config dir (mode 0600): ~/Library/Application Support/dash0/credentials.json (macOS) $XDG_CONFIG_HOME/dash0/credentials.json (Linux) %AppData%\dash0\credentials.json (Windows) - After OAuth, the plugin uses the issued Clerk JWT to mint a long-lived auth_* token via CPA POST /public/ui/organization/auth-tokens. Falls back to the short-lived OAuth access_token when mint fails (e.g. dev envs without admin role). - Refresh-token rotation: offline_access scope yields a refresh token; SessionStart hook auto-refreshes on 401, re-mints, persists, retries CheckConnectivity, and reports the rotated state to the user. - SessionStart printHookResponse always emits a status line: - dash0: telemetry is not active (no OTLP_URL) - dash0: not authenticated (no token) - dash0: auth token rejected (401 after refresh) - dash0: connected as (valid) The "needs login" branches put a Skill-tool nudge into additionalContext so Claude proactively invokes the slash command before continuing with the user's request. - Per-user OAuth client registration caches client_id + the bound loopback port in clients.json so repeat logins reuse the same redirect_uri (Dash0 / Clerk enforce exact match). - New userConfig entries: AUTH_URL, OAUTH_CLIENT_ID (plus env-var fallbacks DASH0_AUTH_URL, DASH0_OAUTH_CLIENT_ID). - Drops the original region selector (us / eu / dev) and the DASH0_REGION env var — the URL-only model matches Dash0's real topology (Clerk for OAuth, CPA for mint, regional API for ingest). Credentials now carry AuthURL, ClientID, RefreshToken instead of Region/APIBase. Clients map keyed by AuthURL. - CI adds an auth-e2e job that runs the mocked flow without an ANTHROPIC_API_KEY secret (works for fork PRs). - 14 e2e scenarios + 160 unit tests cover discover/register/exchange/ mint/refresh paths against an in-process mock Clerk-shaped server. --- .claude-plugin/plugin.json | 22 ++- .github/workflows/ci.yml | 47 +++-- .gitignore | 6 +- README.md | 52 +++-- cmd/on-event/login.go | 109 +++++++++++ cmd/on-event/main.go | 124 ++++++++++-- cmd/on-event/main_test.go | 26 ++- commands/login.md | 24 +++ internal/auth/auth_test.go | 331 ++++++++++++++++++++++++++++++++ internal/auth/browser.go | 36 ++++ internal/auth/login.go | 204 ++++++++++++++++++++ internal/auth/oauth.go | 249 ++++++++++++++++++++++++ internal/auth/pkce.go | 40 ++++ internal/auth/server.go | 135 +++++++++++++ internal/auth/storage.go | 179 +++++++++++++++++ internal/auth/token.go | 93 +++++++++ scripts/login.sh | 62 ++++++ scripts/on-event.sh | 2 +- test/e2e/auth_flow_test.go | 359 +++++++++++++++++++++++++++++++++++ test/e2e/mockdash0/server.go | 174 +++++++++++++++++ 20 files changed, 2224 insertions(+), 50 deletions(-) create mode 100644 cmd/on-event/login.go create mode 100644 commands/login.md create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/browser.go create mode 100644 internal/auth/login.go create mode 100644 internal/auth/oauth.go create mode 100644 internal/auth/pkce.go create mode 100644 internal/auth/server.go create mode 100644 internal/auth/storage.go create mode 100644 internal/auth/token.go create mode 100755 scripts/login.sh create mode 100644 test/e2e/auth_flow_test.go create mode 100644 test/e2e/mockdash0/server.go diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index abada85..34f8329 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,29 @@ "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. Default: https://clerk.dash0.com (auto-switches to https://clerk.dash0-dev.com when OTLP_URL points at a .dash0-dev.com host). Only set this for self-hosted or staging Dash0 instances.", + "type": "string", + "sensitive": false + }, + "OAUTH_CLIENT_ID": { + "title": "OAuth Client ID (advanced)", + "description": "Pre-registered OAuth client_id for the authorization server. Required for production logins against Clerk (which does not support Dynamic Client Registration). Created once in the Clerk Dashboard → OAuth Applications by a Dash0 admin.", + "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..db95158 --- /dev/null +++ b/cmd/on-event/login.go @@ -0,0 +1,109 @@ +// 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 ( + // Clerk OAuth provider hosts. clerk.dash0.com is verified to expose + // RFC 8414 OAuth metadata + RS256-signed JWTs. The dev host follows + // the same naming pattern (DNS may need to be set up). + defaultAuthURL = "https://clerk.dash0.com" + defaultAuthURLDev = "https://clerk.dash0-dev.com" + // defaultScope is what we send on the authorize request. openid + // makes Clerk issue an id_token-style JWT (passes CPA CheckUserAuth), + // email+profile populate claims, offline_access yields a refresh + // token. + defaultScope = "openid email profile offline_access" +) + +func runLogin(args []string) error { + fs := flag.NewFlagSet("login", flag.ContinueOnError) + authURLFlag := fs.String("auth-url", "", "OAuth authorization server root (default: inferred from DASH0_OTLP_URL or DASH0_AUTH_URL, else "+defaultAuthURL+")") + clientIDFlag := fs.String("client-id", "", "Pre-registered OAuth client_id (default: DASH0_OAUTH_CLIENT_ID env)") + scopeFlag := fs.String("scope", "", "OAuth scope (default: "+defaultScope+")") + 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) + clientID := strings.TrimSpace(*clientIDFlag) + if clientID == "" { + clientID = strings.TrimSpace(os.Getenv("CLAUDE_PLUGIN_OPTION_OAUTH_CLIENT_ID")) + } + if clientID == "" { + clientID = strings.TrimSpace(os.Getenv("DASH0_OAUTH_CLIENT_ID")) + } + scope := strings.TrimSpace(*scopeFlag) + if scope == "" { + scope = defaultScope + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + opts := auth.LoginOptions{ + AuthURL: authURL, + ClientID: clientID, + Scope: scope, + 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. DASH0_AUTH_URL env +// 3. DASH0_OTLP_URL / CLAUDE_PLUGIN_OPTION_OTLP_URL hostname sniff +// (.dash0-dev.com → dev Clerk, .dash0.com → prod Clerk) +// 4. defaultAuthURL (prod Clerk) +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() + switch { + case strings.HasSuffix(host, ".dash0-dev.com"): + return defaultAuthURLDev + case strings.HasSuffix(host, ".dash0.com"): + return defaultAuthURL + } + } + } + return defaultAuthURL +} diff --git a/cmd/on-event/main.go b/cmd/on-event/main.go index 8f2e4ba..caed001 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,55 @@ 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 written by /dash0-agent-plugin:login +func resolveAuthToken(creds *auth.Credentials) string { + if v := pluginOptionSecure("AUTH_TOKEN"); v != "" { + return v + } + if creds != nil { + 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 +439,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 +460,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..b8d16aa --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,331 @@ +// 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 + mintCalls atomic.Int32 + failMint bool + failMintCode int + 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/auth-tokens", func(w http.ResponseWriter, r *http.Request) { + m.mintCalls.Add(1) + if m.failMint { + code := m.failMintCode + if code == 0 { + code = http.StatusInternalServerError + } + w.WriteHeader(code) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"token": "auth_test_machine_token"}) + }) + 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) + }) + 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 TestMintMachineToken_NotAdmin(t *testing.T) { + m := newMockDash0Server() + defer m.Close() + m.failMint = true + m.failMintCode = http.StatusForbidden + _, err := MintMachineToken(context.Background(), m.server.URL, "dash0_at_test", "desc") + if err == nil || !errorIs(err, ErrNotAdmin) { + t.Fatalf("expected ErrNotAdmin, got %v", err) + } +} + +func TestMintMachineToken_Success(t *testing.T) { + m := newMockDash0Server() + defer m.Close() + token, err := MintMachineToken(context.Background(), m.server.URL, "dash0_at_test", "desc") + if err != nil { + t.Fatalf("MintMachineToken: %v", err) + } + if token != "auth_test_machine_token" { + t.Fatalf("unexpected token: %s", token) + } +} + +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) + } +} + +// errorIs is a tiny shim that avoids importing errors in test code paths where +// we want behavioural matching against sentinel errors. Implemented inline so +// the test file compiles without extra deps. +func errorIs(err, target error) bool { + for err != nil { + if err == target { + return true + } + type wrapper interface{ Unwrap() error } + if w, ok := err.(wrapper); ok { + err = w.Unwrap() + continue + } + return false + } + return false +} 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..90c90d4 --- /dev/null +++ b/internal/auth/login.go @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +package auth + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "time" +) + +// deriveCPAURL maps a Dash0-flavored OAuth host (regional API or Clerk) +// to its control-plane-api host. The CPA hosts the mint endpoint and +// middleware-info. Returns "" when authURL doesn't contain a ".dash0" +// hostname segment. +func deriveCPAURL(authURL string) string { + _, suffix, ok := strings.Cut(authURL, ".dash0") + if !ok { + return "" + } + return "https://control-plane-api.dash0" + suffix +} + +// LoginOptions controls the high-level Login orchestration. +type LoginOptions struct { + // AuthURL is the OAuth authorization server root (e.g. + // https://clerk.dash0.com). Required. + AuthURL string + // ClientID is the pre-registered OAuth client identifier. When empty + // the plugin falls back to Dynamic Client Registration (RFC 7591) + // against AuthURL's registration_endpoint — used by the test mock + // and any Dash0-internal OAuth server that supports DCR. Clerk does + // not support DCR, so production logins must supply ClientID. + ClientID string + // Scope is the OAuth scope string included in the authorize and + // token requests. Defaults to "" (meaning "no scope param sent"), + // which works for Dash0's native OAuth. For Clerk, callers should + // pass "openid email profile offline_access". + Scope string + // ClientName / ClientURI are sent during Dynamic Client Registration + // (when ClientID is empty). + 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] + + // When a pre-registered ClientID is supplied (Clerk path), override + // whatever's cached in clients.json for this AuthURL. + if opts.ClientID != "" && clientEntry.ClientID != opts.ClientID { + clientEntry = ClientEntry{ClientID: opts.ClientID, Port: clientEntry.Port} + exists = true + } + + server, err := StartCallbackServer(clientEntry.Port) + if err != nil { + return nil, err + } + if exists && clientEntry.Port != 0 && server.Port() != clientEntry.Port { + // Stored port was taken; we got a random one instead. The stored + // redirect_uri no longer matches, so we need a fresh registration + // (only possible when DCR is available). + exists = opts.ClientID != "" + } + if !exists { + if meta.RegistrationEndpoint == "" { + return nil, fmt.Errorf("auth server does not support Dynamic Client Registration and no ClientID was provided") + } + 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) + } + // Persist whichever client (DCR or pre-registered) we ended up using, + // along with the bound port so the next login reuses the same + // redirect_uri. + 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, opts.Scope) + + 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 + } + + mintURL := deriveCPAURL(opts.AuthURL) + if mintURL == "" { + mintURL = opts.AuthURL + } + + // Try to mint a long-lived auth_* token using the access_token. Works + // when the access_token is a real JWT (Clerk OAuth provider) since the + // CPA's CheckUserAuth accepts JWTs only. If mint fails, fall back to + // using the access_token directly for OTLP ingest. + authToken := tok.AccessToken + if minted, err := MintMachineToken(ctx, mintURL, tok.AccessToken, "Dash0 Claude Code Plugin — auto-generated"); err == nil { + authToken = minted + fmt.Fprintf(opts.Stdout, "dash0: minted long-lived ingestion token\n") + } else if errors.Is(err, ErrNotAdmin) { + return nil, fmt.Errorf("You need admin access to mint an API token. Ask your organization admin to generate one and set DASH0_AUTH_TOKEN=.") + } else { + fmt.Fprintf(opts.Stderr, "dash0: could not mint long-lived token (%v); falling back to the short-lived access token (refresh-token rotation will kick in automatically).\n", err) + } + + orgInfo, _ := FetchOrganizationInfo(ctx, mintURL, tok.AccessToken) + ingressURL := "" + if orgInfo != nil && orgInfo.IngressURL != "" { + ingressURL = orgInfo.IngressURL + } + + orgID := tok.OrganizationTechnicalID + if orgID == "" && orgInfo != nil { + orgID = orgInfo.TechnicalID + } + + creds := Credentials{ + AuthToken: authToken, + 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..8c94cd3 --- /dev/null +++ b/internal/auth/oauth.go @@ -0,0 +1,249 @@ +// 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, attempts to re-mint a long-lived auth_* via the CPA mint +// endpoint, 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 + } + + mintURL := deriveCPAURL(creds.AuthURL) + if mintURL == "" { + mintURL = creds.AuthURL + } + authToken := tok.AccessToken + if minted, mintErr := MintMachineToken(ctx, mintURL, tok.AccessToken, "Dash0 Claude Code Plugin — refreshed"); mintErr == nil { + authToken = minted + } + + updated := *creds + updated.AuthToken = authToken + 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 +} + +// 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..a44a39e --- /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://localhost:%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..bed43d1 --- /dev/null +++ b/internal/auth/storage.go @@ -0,0 +1,179 @@ +// 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 what the hook attaches to outgoing OTLP requests. + // It is either a long-lived auth_* token (when CPA mint succeeded + // during login) or the short-lived OAuth access_token (when mint + // was skipped/failed). The hook code refreshes it on 401 by using + // RefreshToken against AuthURL. + AuthToken string `json:"auth_token"` + // RefreshToken is used to obtain a fresh AuthToken when the current + // one is rejected. Only present when the OAuth server issues one + // (Clerk does when offline_access scope is requested). + 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..ac595c5 --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc. + +package auth + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +// ErrNotAdmin is returned by MintMachineToken when the server replies 403. +var ErrNotAdmin = errors.New("not an organization admin") + +// MintMachineToken calls POST /public/ui/organization/auth-tokens to mint +// a long-lived machine token. Returns ErrNotAdmin on 403. +func MintMachineToken(ctx context.Context, apiBase, accessToken, description string) (string, error) { + u := strings.TrimRight(apiBase, "/") + "/public/ui/organization/auth-tokens" + body, _ := json.Marshal(map[string]string{"description": description}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := httpClient().Do(req) + if err != nil { + return "", fmt.Errorf("minting machine token: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusForbidden { + return "", ErrNotAdmin + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14)) + return "", fmt.Errorf("mint 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 mint response: %w", err) + } + if out.Token == "" { + return "", fmt.Errorf("mint response missing token field") + } + return out.Token, nil +} + +// 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/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..74ab8f3 --- /dev/null +++ b/test/e2e/auth_flow_test.go @@ -0,0 +1,359 @@ +// 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, "auth_mock_machine_token", creds["auth_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, "auth_mock_machine_token", 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..39751ae --- /dev/null +++ b/test/e2e/mockdash0/server.go @@ -0,0 +1,174 @@ +// 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 +// - POST /public/ui/organization/auth-tokens +// - GET /public/ui/organization/me +// - 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 + FailMint bool + FailMintStatus int +} + +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) + if cr.Form["grant_type"] != "authorization_code" { + http.Error(w, "unsupported_grant_type", http.StatusBadRequest) + return + } + _ = 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", + }) + }) + + mux.HandleFunc("/public/ui/organization/auth-tokens", func(w http.ResponseWriter, r *http.Request) { + s.capture(r) + if s.FailMint { + code := s.FailMintStatus + if code == 0 { + code = http.StatusInternalServerError + } + w.WriteHeader(code) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"token": "auth_mock_machine_token"}) + }) + + 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("/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 +} From 175527984b9d97800d4ce79c576967ae690c94d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Conrad=20P=C3=B6pke?= Date: Mon, 18 May 2026 00:39:16 +0200 Subject: [PATCH 2/3] style: gofmt internal/otlp + internal/transcript Pure formatting (struct-tag alignment). No behavior changes. --- internal/otlp/otlp_test.go | 12 ++++++------ internal/transcript/transcript.go | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) 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 { From 84693ea224dabd4d04d7737737f9e2f8f351b248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Conrad=20P=C3=B6pke?= Date: Mon, 29 Jun 2026 23:24:59 +0200 Subject: [PATCH 3/3] feat(auth): provision auth_* ingestion token after OAuth login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Clerk-specific MintMachineToken path (POST /public/ui/organization/auth-tokens) with a call to PUT /api/auth-tokens/claude-code-plugin — the new idempotent get-or-create endpoint added to the CPA. This removes the admin-only constraint: any org member with allowMembersClaudeCodeAccess=true can now log in and get a permanent ingestion token. Changes: - Add ProvisionIngestionToken() calling PUT /api/auth-tokens/claude-code-plugin - Add IngestionToken field to Credentials; OTLP hook prefers it over AuthToken - Remove MintMachineToken, ErrNotAdmin, deriveCPAURL (all obsolete) - Remove Clerk-specific ClientID/Scope login options and OAUTH_CLIENT_ID plugin config - RefreshCredentials no longer attempts to re-mint; ingestion token persists across refreshes --- .claude-plugin/plugin.json | 8 +--- cmd/on-event/login.go | 47 ++++++--------------- cmd/on-event/main.go | 6 ++- internal/auth/auth_test.go | 65 ++++++----------------------- internal/auth/login.go | 80 +++++++----------------------------- internal/auth/oauth.go | 46 +++++++++++++++------ internal/auth/server.go | 2 +- internal/auth/storage.go | 17 ++++---- internal/auth/token.go | 41 ------------------ test/e2e/auth_flow_test.go | 5 ++- test/e2e/mockdash0/server.go | 54 ++++++++++++------------ 11 files changed, 121 insertions(+), 250 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 34f8329..635bbca 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -24,13 +24,7 @@ }, "AUTH_URL": { "title": "OAuth Authorization Server URL (advanced)", - "description": "Override the OAuth authorization server used by /dash0-agent-plugin:login. Default: https://clerk.dash0.com (auto-switches to https://clerk.dash0-dev.com when OTLP_URL points at a .dash0-dev.com host). Only set this for self-hosted or staging Dash0 instances.", - "type": "string", - "sensitive": false - }, - "OAUTH_CLIENT_ID": { - "title": "OAuth Client ID (advanced)", - "description": "Pre-registered OAuth client_id for the authorization server. Required for production logins against Clerk (which does not support Dynamic Client Registration). Created once in the Clerk Dashboard → OAuth Applications by a Dash0 admin.", + "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 }, diff --git a/cmd/on-event/login.go b/cmd/on-event/login.go index db95158..4b07758 100644 --- a/cmd/on-event/login.go +++ b/cmd/on-event/login.go @@ -16,48 +16,27 @@ import ( ) const ( - // Clerk OAuth provider hosts. clerk.dash0.com is verified to expose - // RFC 8414 OAuth metadata + RS256-signed JWTs. The dev host follows - // the same naming pattern (DNS may need to be set up). - defaultAuthURL = "https://clerk.dash0.com" - defaultAuthURLDev = "https://clerk.dash0-dev.com" - // defaultScope is what we send on the authorize request. openid - // makes Clerk issue an id_token-style JWT (passes CPA CheckUserAuth), - // email+profile populate claims, offline_access yields a refresh - // token. - defaultScope = "openid email profile offline_access" + // 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 DASH0_OTLP_URL or DASH0_AUTH_URL, else "+defaultAuthURL+")") - clientIDFlag := fs.String("client-id", "", "Pre-registered OAuth client_id (default: DASH0_OAUTH_CLIENT_ID env)") - scopeFlag := fs.String("scope", "", "OAuth scope (default: "+defaultScope+")") + 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) - clientID := strings.TrimSpace(*clientIDFlag) - if clientID == "" { - clientID = strings.TrimSpace(os.Getenv("CLAUDE_PLUGIN_OPTION_OAUTH_CLIENT_ID")) - } - if clientID == "" { - clientID = strings.TrimSpace(os.Getenv("DASH0_OAUTH_CLIENT_ID")) - } - scope := strings.TrimSpace(*scopeFlag) - if scope == "" { - scope = defaultScope - } ctx, cancel := context.WithCancel(context.Background()) defer cancel() opts := auth.LoginOptions{ AuthURL: authURL, - ClientID: clientID, - Scope: scope, ClientName: "Dash0 Claude Code Plugin", ClientURI: "https://github.com/dash0hq/dash0-agent-plugin", CallbackTimeout: *timeout, @@ -76,10 +55,11 @@ func runLogin(args []string) error { // resolveAuthURLForLogin picks the OAuth host based on (in order): // 1. --auth-url flag -// 2. DASH0_AUTH_URL env -// 3. DASH0_OTLP_URL / CLAUDE_PLUGIN_OPTION_OTLP_URL hostname sniff -// (.dash0-dev.com → dev Clerk, .dash0.com → prod Clerk) -// 4. defaultAuthURL (prod Clerk) +// 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 @@ -97,11 +77,8 @@ func resolveAuthURLForLogin(flagValue string) string { if otlp != "" { if u, err := url.Parse(otlp); err == nil { host := u.Hostname() - switch { - case strings.HasSuffix(host, ".dash0-dev.com"): - return defaultAuthURLDev - case strings.HasSuffix(host, ".dash0.com"): - return defaultAuthURL + if strings.HasPrefix(host, "ingress.") { + return "https://api." + strings.TrimPrefix(host, "ingress.") } } } diff --git a/cmd/on-event/main.go b/cmd/on-event/main.go index caed001..a673f4f 100644 --- a/cmd/on-event/main.go +++ b/cmd/on-event/main.go @@ -299,12 +299,16 @@ func pluginOptionBoolDefault(key string, defaultVal bool) bool { // resolveAuthToken returns the auth token for OTLP ingestion. Precedence: // 1. CLAUDE_PLUGIN_OPTION_AUTH_TOKEN (manual paste in /plugin Configure) -// 2. credentials.json written by /dash0-agent-plugin:login +// 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 "" diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index b8d16aa..71230fa 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -176,9 +176,6 @@ func TestCallbackServer_Error(t *testing.T) { type mockDash0Server struct { server *httptest.Server registered atomic.Int32 - mintCalls atomic.Int32 - failMint bool - failMintCode int omitOrgEp bool overrideOrg *OrganizationInfo overrideCode string @@ -222,18 +219,6 @@ func newMockDash0Server() *mockDash0Server { OrganizationTechnicalID: "mock-org", }) }) - mux.HandleFunc("/public/ui/organization/auth-tokens", func(w http.ResponseWriter, r *http.Request) { - m.mintCalls.Add(1) - if m.failMint { - code := m.failMintCode - if code == 0 { - code = http.StatusInternalServerError - } - w.WriteHeader(code) - return - } - _ = json.NewEncoder(w).Encode(map[string]string{"token": "auth_test_machine_token"}) - }) mux.HandleFunc("/public/ui/organization/me", func(w http.ResponseWriter, r *http.Request) { if m.omitOrgEp { w.WriteHeader(http.StatusNotFound) @@ -245,6 +230,9 @@ func newMockDash0Server() *mockDash0Server { } _ = 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 } @@ -276,29 +264,6 @@ func TestRegisterClient(t *testing.T) { } } -func TestMintMachineToken_NotAdmin(t *testing.T) { - m := newMockDash0Server() - defer m.Close() - m.failMint = true - m.failMintCode = http.StatusForbidden - _, err := MintMachineToken(context.Background(), m.server.URL, "dash0_at_test", "desc") - if err == nil || !errorIs(err, ErrNotAdmin) { - t.Fatalf("expected ErrNotAdmin, got %v", err) - } -} - -func TestMintMachineToken_Success(t *testing.T) { - m := newMockDash0Server() - defer m.Close() - token, err := MintMachineToken(context.Background(), m.server.URL, "dash0_at_test", "desc") - if err != nil { - t.Fatalf("MintMachineToken: %v", err) - } - if token != "auth_test_machine_token" { - t.Fatalf("unexpected token: %s", token) - } -} - func TestFetchOrganizationInfo_404IsNotError(t *testing.T) { m := newMockDash0Server() defer m.Close() @@ -312,20 +277,14 @@ func TestFetchOrganizationInfo_404IsNotError(t *testing.T) { } } -// errorIs is a tiny shim that avoids importing errors in test code paths where -// we want behavioural matching against sentinel errors. Implemented inline so -// the test file compiles without extra deps. -func errorIs(err, target error) bool { - for err != nil { - if err == target { - return true - } - type wrapper interface{ Unwrap() error } - if w, ok := err.(wrapper); ok { - err = w.Unwrap() - continue - } - return false +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) } - return false } diff --git a/internal/auth/login.go b/internal/auth/login.go index 90c90d4..4d2ba57 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -4,44 +4,18 @@ package auth import ( "context" - "errors" "fmt" "io" "os" - "strings" "time" ) -// deriveCPAURL maps a Dash0-flavored OAuth host (regional API or Clerk) -// to its control-plane-api host. The CPA hosts the mint endpoint and -// middleware-info. Returns "" when authURL doesn't contain a ".dash0" -// hostname segment. -func deriveCPAURL(authURL string) string { - _, suffix, ok := strings.Cut(authURL, ".dash0") - if !ok { - return "" - } - return "https://control-plane-api.dash0" + suffix -} - // LoginOptions controls the high-level Login orchestration. type LoginOptions struct { // AuthURL is the OAuth authorization server root (e.g. - // https://clerk.dash0.com). Required. + // https://api.eu-west-1.aws.dash0.com). Required. AuthURL string - // ClientID is the pre-registered OAuth client identifier. When empty - // the plugin falls back to Dynamic Client Registration (RFC 7591) - // against AuthURL's registration_endpoint — used by the test mock - // and any Dash0-internal OAuth server that supports DCR. Clerk does - // not support DCR, so production logins must supply ClientID. - ClientID string - // Scope is the OAuth scope string included in the authorize and - // token requests. Defaults to "" (meaning "no scope param sent"), - // which works for Dash0's native OAuth. For Clerk, callers should - // pass "openid email profile offline_access". - Scope string - // ClientName / ClientURI are sent during Dynamic Client Registration - // (when ClientID is empty). + // ClientName / ClientURI are sent during Dynamic Client Registration. ClientName string ClientURI string // CallbackTimeout bounds the wait for the redirect. @@ -88,26 +62,18 @@ func Login(ctx context.Context, opts LoginOptions) (*LoginResult, error) { clientKey := opts.AuthURL clientEntry, exists := clients.Clients[clientKey] - // When a pre-registered ClientID is supplied (Clerk path), override - // whatever's cached in clients.json for this AuthURL. - if opts.ClientID != "" && clientEntry.ClientID != opts.ClientID { - clientEntry = ClientEntry{ClientID: opts.ClientID, Port: clientEntry.Port} - exists = true - } - server, err := StartCallbackServer(clientEntry.Port) if err != nil { return nil, err } if exists && clientEntry.Port != 0 && server.Port() != clientEntry.Port { - // Stored port was taken; we got a random one instead. The stored - // redirect_uri no longer matches, so we need a fresh registration - // (only possible when DCR is available). - exists = opts.ClientID != "" + // 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 and no ClientID was provided") + 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 { @@ -116,9 +82,6 @@ func Login(ctx context.Context, opts LoginOptions) (*LoginResult, error) { clientEntry = ClientEntry{ClientID: clientID, Port: server.Port()} fmt.Fprintf(opts.Stdout, "dash0: registered OAuth client (id %s)\n", clientID) } - // Persist whichever client (DCR or pre-registered) we ended up using, - // along with the bound port so the next login reuses the same - // redirect_uri. clientEntry.Port = server.Port() clients.Clients[clientKey] = clientEntry if err := SaveClients(clients); err != nil { @@ -129,7 +92,7 @@ func Login(ctx context.Context, opts LoginOptions) (*LoginResult, error) { if err != nil { return nil, err } - authorizeURL := BuildAuthorizeURL(meta, clientEntry.ClientID, server.URL, pkce, opts.Scope) + 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) @@ -157,26 +120,7 @@ func Login(ctx context.Context, opts LoginOptions) (*LoginResult, error) { return nil, err } - mintURL := deriveCPAURL(opts.AuthURL) - if mintURL == "" { - mintURL = opts.AuthURL - } - - // Try to mint a long-lived auth_* token using the access_token. Works - // when the access_token is a real JWT (Clerk OAuth provider) since the - // CPA's CheckUserAuth accepts JWTs only. If mint fails, fall back to - // using the access_token directly for OTLP ingest. - authToken := tok.AccessToken - if minted, err := MintMachineToken(ctx, mintURL, tok.AccessToken, "Dash0 Claude Code Plugin — auto-generated"); err == nil { - authToken = minted - fmt.Fprintf(opts.Stdout, "dash0: minted long-lived ingestion token\n") - } else if errors.Is(err, ErrNotAdmin) { - return nil, fmt.Errorf("You need admin access to mint an API token. Ask your organization admin to generate one and set DASH0_AUTH_TOKEN=.") - } else { - fmt.Fprintf(opts.Stderr, "dash0: could not mint long-lived token (%v); falling back to the short-lived access token (refresh-token rotation will kick in automatically).\n", err) - } - - orgInfo, _ := FetchOrganizationInfo(ctx, mintURL, tok.AccessToken) + orgInfo, _ := FetchOrganizationInfo(ctx, opts.AuthURL, tok.AccessToken) ingressURL := "" if orgInfo != nil && orgInfo.IngressURL != "" { ingressURL = orgInfo.IngressURL @@ -187,8 +131,14 @@ func Login(ctx context.Context, opts LoginOptions) (*LoginResult, error) { 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: authToken, + AuthToken: tok.AccessToken, + IngestionToken: ingestionToken, RefreshToken: tok.RefreshToken, OrganizationTechnicalID: orgID, AuthURL: opts.AuthURL, diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 8c94cd3..b5b11a0 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -131,8 +131,7 @@ func BuildAuthorizeURL(meta *Metadata, clientID, redirectURI string, pkce *PKCE, } // RefreshCredentials uses the refresh_token in creds to obtain a fresh -// access_token, attempts to re-mint a long-lived auth_* via the CPA mint -// endpoint, and persists the updated credentials. Returns the updated +// 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 @@ -158,17 +157,8 @@ func RefreshCredentials(ctx context.Context, creds *Credentials) (*Credentials, return nil, err } - mintURL := deriveCPAURL(creds.AuthURL) - if mintURL == "" { - mintURL = creds.AuthURL - } - authToken := tok.AccessToken - if minted, mintErr := MintMachineToken(ctx, mintURL, tok.AccessToken, "Dash0 Claude Code Plugin — refreshed"); mintErr == nil { - authToken = minted - } - updated := *creds - updated.AuthToken = authToken + updated.AuthToken = tok.AccessToken if tok.RefreshToken != "" { updated.RefreshToken = tok.RefreshToken } @@ -213,6 +203,38 @@ func RefreshAccessToken(ctx context.Context, tokenEndpoint, clientID, refreshTok 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) { diff --git a/internal/auth/server.go b/internal/auth/server.go index a44a39e..69259bf 100644 --- a/internal/auth/server.go +++ b/internal/auth/server.go @@ -55,7 +55,7 @@ func StartCallbackServer(desiredPort int) (*CallbackServer, error) { func newCallbackServer(listener net.Listener) *CallbackServer { port := listener.Addr().(*net.TCPAddr).Port cs := &CallbackServer{ - URL: fmt.Sprintf("http://localhost:%d/callback", port), + URL: fmt.Sprintf("http://127.0.0.1:%d/callback", port), port: port, result: make(chan CallbackResult, 1), } diff --git a/internal/auth/storage.go b/internal/auth/storage.go index bed43d1..775b4ee 100644 --- a/internal/auth/storage.go +++ b/internal/auth/storage.go @@ -11,15 +11,18 @@ import ( // Credentials is the persisted state used by the OTLP hook to authenticate. type Credentials struct { - // AuthToken is what the hook attaches to outgoing OTLP requests. - // It is either a long-lived auth_* token (when CPA mint succeeded - // during login) or the short-lived OAuth access_token (when mint - // was skipped/failed). The hook code refreshes it on 401 by using - // RefreshToken against AuthURL. + // 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 is rejected. Only present when the OAuth server issues one - // (Clerk does when offline_access scope is requested). + // 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"` diff --git a/internal/auth/token.go b/internal/auth/token.go index ac595c5..e28730c 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -3,55 +3,14 @@ package auth import ( - "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" "strings" ) -// ErrNotAdmin is returned by MintMachineToken when the server replies 403. -var ErrNotAdmin = errors.New("not an organization admin") - -// MintMachineToken calls POST /public/ui/organization/auth-tokens to mint -// a long-lived machine token. Returns ErrNotAdmin on 403. -func MintMachineToken(ctx context.Context, apiBase, accessToken, description string) (string, error) { - u := strings.TrimRight(apiBase, "/") + "/public/ui/organization/auth-tokens" - body, _ := json.Marshal(map[string]string{"description": description}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body)) - if err != nil { - return "", err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - resp, err := httpClient().Do(req) - if err != nil { - return "", fmt.Errorf("minting machine token: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusForbidden { - return "", ErrNotAdmin - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14)) - return "", fmt.Errorf("mint 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 mint response: %w", err) - } - if out.Token == "" { - return "", fmt.Errorf("mint response missing token field") - } - return out.Token, nil -} - // OrganizationInfo is the subset of org metadata we use to lock in the // correct ingestion URL after login. type OrganizationInfo struct { diff --git a/test/e2e/auth_flow_test.go b/test/e2e/auth_flow_test.go index 74ab8f3..7a63834 100644 --- a/test/e2e/auth_flow_test.go +++ b/test/e2e/auth_flow_test.go @@ -173,7 +173,8 @@ func TestAuthFlow_NewUser(t *testing.T) { creds := f.credentials() require.NotNil(t, creds) - assert.Equal(t, "auth_mock_machine_token", creds["auth_token"]) + 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"]) @@ -201,7 +202,7 @@ func TestAuthFlow_ExistingUserSkipsRegister(t *testing.T) { for _, r := range f.mockState.Requests { assert.NotEqual(t, "/oauth/register", r.Path, "register should be skipped when client.json exists") } - assert.Equal(t, "auth_mock_machine_token", f.credentials()["auth_token"]) + assert.Equal(t, "dash0_at_mock", f.credentials()["auth_token"]) } // 4 — tamper with `state` in callback; binary must reject. diff --git a/test/e2e/mockdash0/server.go b/test/e2e/mockdash0/server.go index 39751ae..964de63 100644 --- a/test/e2e/mockdash0/server.go +++ b/test/e2e/mockdash0/server.go @@ -9,8 +9,8 @@ // - POST /oauth/register // - GET /oauth/authorize (auto-consents and 302s to redirect_uri) // - POST /oauth/token -// - POST /public/ui/organization/auth-tokens // - GET /public/ui/organization/me +// - PUT /api/auth-tokens/{originOrId} // - GET /requests (test-only: dumps captured requests) package mockdash0 @@ -37,8 +37,9 @@ type State struct { Requests []CapturedRequest IngressURL string // value returned by /organization/me OrgEndpoint404 bool - FailMint bool - FailMintStatus int + // IngestionToken is returned by PUT /api/auth-tokens/{originOrId}. + // Defaults to "auth_mock" when empty. + IngestionToken string } func NewState() *State { @@ -121,31 +122,19 @@ func Handler(s *State, baseURL func() string) http.Handler { mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { cr := s.capture(r) - if cr.Form["grant_type"] != "authorization_code" { + 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) - return } - _ = 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", - }) - }) - - mux.HandleFunc("/public/ui/organization/auth-tokens", func(w http.ResponseWriter, r *http.Request) { - s.capture(r) - if s.FailMint { - code := s.FailMintStatus - if code == 0 { - code = http.StatusInternalServerError - } - w.WriteHeader(code) - return - } - _ = json.NewEncoder(w).Encode(map[string]string{"token": "auth_mock_machine_token"}) }) mux.HandleFunc("/public/ui/organization/me", func(w http.ResponseWriter, r *http.Request) { @@ -161,6 +150,19 @@ func Handler(s *State, baseURL func() string) http.Handler { _ = 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()