diff --git a/README.md b/README.md index 61f9861..ce9ba47 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # sherlock -Per-operator credential broker + agent-CLI wrapper for the Charlie homelab. Holds the Authentik session once, injects per-service tokens into MCP servers, and execs the agent CLI of your choice (Copilot today, Claude Code or any other MCP-aware agent tomorrow). +Per-operator credential wallet + agent-CLI wrapper for the Charlie homelab. Holds one OAuth session per service in the OS keyring, exposes a shared auth library that MCPs call lazily at startup, and execs the agent CLI of your choice (Copilot today, Claude Code or any other MCP-aware agent tomorrow). Design + phasing: [plan.md](plan.md). diff --git a/cmd/sherlock/main.go b/cmd/sherlock/main.go index 2e7c623..5502dff 100644 --- a/cmd/sherlock/main.go +++ b/cmd/sherlock/main.go @@ -1,24 +1,23 @@ -// Command sherlock is the operator's single binary: it logs in to -// Authentik (one-shot loopback HTTP listener), persists tokens in the -// OS keyring, and dispatches `sherlock ` to the matching agent -// CLI (Copilot, Claude Code, …). There is no daemon — refresh races -// across concurrent invocations are serialised via flock on a -// per-user lock file. See docs/architecture.md. +// Command sherlock is the operator's single binary. It is a wallet +// of OAuth sessions (one per service: gitea, grafana, miniflux, …) +// plus an agent-CLI wrapper that execs Copilot, Claude Code, etc. +// +// There is no `sherlock login` step. Authentication happens lazily, +// when an MCP started by the agent calls authn.Ensure for the first +// time. Sherlock's job is to provide the shared auth library and the +// wallet; this binary exposes the wallet via `sherlock status` and +// `sherlock logout`, and dispatches `sherlock ` to the right +// CLI integration. See docs/architecture.md. package main import ( - "context" "errors" - "flag" "fmt" "os" - "os/exec" - "runtime" "strings" "time" "gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent" - "gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn" "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring" ) @@ -29,15 +28,6 @@ const ( exitGeneric = 1 exitUsage = 2 exitKeyring = 3 - exitAuth = 5 -) - -const ( - envIssuer = "SHERLOCK_AUTHENTIK_ISSUER" - envClientID = "SHERLOCK_AUTHENTIK_CLIENT_ID" - envScopes = "SHERLOCK_AUTHENTIK_SCOPES" - - defaultScopes = "openid profile email offline_access" ) func main() { @@ -64,12 +54,10 @@ func main() { } switch sub { - case "login": - runLogin(store, rest) - case "logout": - runLogout(store, rest) case "status": runStatus(store, rest) + case "logout": + runLogout(store, rest) case "run": if len(rest) == 0 { fmt.Fprintln(os.Stderr, "sherlock: run requires an agent name") @@ -94,102 +82,48 @@ Usage: sherlock [args...] Subcommands: - login Open a browser, log in to Authentik, persist tokens. - logout Clear stored tokens. - status Show login state, token expiry, available agents. - run [args...] Spawn an agent. - [args...] Alias for `+"`run ...`"+`. Known agents: %s. - version Print the sherlock version and exit. + status Show wallet contents (one entry per service). + logout [] Forget all stored tokens, or just one service's. + run [args...] Spawn an agent. + [args...] Alias for `+"`run ...`"+`. Known agents: %s. + version Print the sherlock version and exit. -Environment (consumed by `+"`sherlock login`"+`): - %s Authentik OIDC issuer URL (required). - %s Authentik OAuth2 client ID (required). - %s Space-separated scopes (default: %q). -`, - strings.Join(agent.Names(), ", "), - envIssuer, envClientID, envScopes, defaultScopes) -} - -func loadAuthnConfig() authn.Config { - scopes := defaultScopes - if v := os.Getenv(envScopes); v != "" { - scopes = v - } - return authn.Config{ - Issuer: os.Getenv(envIssuer), - ClientID: os.Getenv(envClientID), - Scopes: strings.Fields(scopes), - } -} - -func runLogin(store keyring.Store, args []string) { - fs := flag.NewFlagSet("login", flag.ExitOnError) - timeout := fs.Duration("timeout", 5*time.Minute, "how long to wait for the browser callback") - _ = fs.Parse(args) - - cfg := loadAuthnConfig() - if !cfg.Configured() { - fmt.Fprintf(os.Stderr, - "sherlock: Authentik not configured. Set %s and %s in your environment.\n", - envIssuer, envClientID) - os.Exit(exitAuth) - } - - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - - opts := authn.LoginOptions{ - OnAuthURL: func(u string) { - fmt.Println("Opening browser to:") - fmt.Println(" ", u) - if err := openBrowser(u); err != nil { - fmt.Fprintln(os.Stderr, "sherlock: open browser:", err) - fmt.Println("(Browser didn't open — visit the URL above manually.)") - } - }, - } - - res, err := authn.Login(ctx, cfg, opts) - if err != nil { - fmt.Fprintln(os.Stderr, "sherlock: login:", err) - os.Exit(exitAuth) - } - if err := store.SetAuthentikTokens(res.Tokens); err != nil { - fmt.Fprintln(os.Stderr, "sherlock: persist tokens:", err) - os.Exit(exitKeyring) - } - fmt.Printf("Logged in as %s <%s>.\n", res.Tokens.Name, res.Tokens.Email) -} - -func runLogout(store keyring.Store, _ []string) { - if err := store.ClearAuthentikTokens(); err != nil { - fmt.Fprintln(os.Stderr, "sherlock: logout:", err) - os.Exit(exitKeyring) - } - fmt.Println("Logged out.") +Authentication is not a user-initiated step. The first time an MCP +needs a token for a service, it triggers the OAuth flow itself via +internal/authn.Ensure (which opens a browser, persists the result in +the wallet, and silently refreshes thereafter). Use `+"`sherlock status`"+` +to see what the wallet currently holds. +`, strings.Join(agent.Names(), ", ")) } func runStatus(store keyring.Store, _ []string) { - ts, err := store.GetAuthentikTokens() - switch { - case errors.Is(err, keyring.ErrNoTokens): - fmt.Println("Not logged in.") - case err != nil: - fmt.Fprintln(os.Stderr, "sherlock: status:", err) + names, err := store.List() + if err != nil { + fmt.Fprintln(os.Stderr, "sherlock: list wallet:", err) os.Exit(exitKeyring) - default: - fmt.Printf("Logged in as %s <%s>\n", ts.Name, ts.Email) - fmt.Printf(" subject: %s\n", ts.Subject) - fmt.Printf(" issuer: %s\n", ts.Issuer) - if !ts.IDExpiresAt.IsZero() { - fmt.Printf(" id token until: %s (%s)\n", - ts.IDExpiresAt.Format(time.RFC3339), - humanizeUntil(ts.IDExpiresAt)) - } - if !ts.RefreshExpAt.IsZero() { - fmt.Printf(" refresh until: %s (%s)\n", - ts.RefreshExpAt.Format(time.RFC3339), - humanizeUntil(ts.RefreshExpAt)) + } + if len(names) == 0 { + fmt.Println("Wallet is empty. MCPs will authenticate on first use.") + } else { + fmt.Println("Wallet:") + for _, n := range names { + ts, err := store.Get(n) + if err != nil { + fmt.Printf(" - %-12s (error: %v)\n", n, err) + continue + } + fmt.Printf(" - %-12s %s <%s>\n", n, ts.Name, ts.Email) + fmt.Printf(" issuer: %s\n", ts.Issuer) + if !ts.IDExpiresAt.IsZero() { + fmt.Printf(" id token until %s (%s)\n", + ts.IDExpiresAt.Format(time.RFC3339), + humanizeUntil(ts.IDExpiresAt)) + } + if !ts.RefreshExpAt.IsZero() { + fmt.Printf(" refresh until %s (%s)\n", + ts.RefreshExpAt.Format(time.RFC3339), + humanizeUntil(ts.RefreshExpAt)) + } } } if names := agent.Names(); len(names) > 0 { @@ -201,6 +135,41 @@ func runStatus(store keyring.Store, _ []string) { } } +func runLogout(store keyring.Store, args []string) { + if len(args) == 0 { + names, err := store.List() + if err != nil { + fmt.Fprintln(os.Stderr, "sherlock: list wallet:", err) + os.Exit(exitKeyring) + } + if len(names) == 0 { + fmt.Println("Wallet was already empty.") + return + } + for _, n := range names { + if err := store.Clear(n); err != nil { + fmt.Fprintln(os.Stderr, "sherlock: clear", n+":", err) + os.Exit(exitKeyring) + } + } + fmt.Printf("Forgot %d session(s): %s.\n", len(names), strings.Join(names, ", ")) + return + } + name := args[0] + if _, err := store.Get(name); errors.Is(err, keyring.ErrNoTokens) { + fmt.Printf("No session stored for %q.\n", name) + return + } else if err != nil { + fmt.Fprintln(os.Stderr, "sherlock:", err) + os.Exit(exitKeyring) + } + if err := store.Clear(name); err != nil { + fmt.Fprintln(os.Stderr, "sherlock:", err) + os.Exit(exitKeyring) + } + fmt.Printf("Forgot %q.\n", name) +} + func runAgent(name string, userArgs []string) { a, ok := agent.Get(name) if !ok { @@ -213,28 +182,6 @@ func runAgent(name string, userArgs []string) { } } -func openBrowser(url string) error { - var bin string - var args []string - switch runtime.GOOS { - case "darwin": - bin = "open" - args = []string{url} - case "windows": - bin = "rundll32" - args = []string{"url.dll,FileProtocolHandler", url} - default: - bin = "xdg-open" - args = []string{url} - } - cmd := exec.Command(bin, args...) - if err := cmd.Start(); err != nil { - return err - } - go func() { _ = cmd.Wait() }() - return nil -} - func humanizeUntil(t time.Time) string { d := time.Until(t).Round(time.Second) if d < 0 { diff --git a/docs/agents.md b/docs/agents.md index 0a92611..48c7bf9 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -27,13 +27,9 @@ bare alias is the daily-driver form. 1. `keyring.Open()` — fail fast if the OS keyring isn't available (returns `*UnavailableError` with a remediation `Hint` field). 2. Resolve the agent binary on `$PATH`. Friendly error if missing. -3. Render the per-agent MCP config to - `$XDG_RUNTIME_DIR/sherlock/.mcp.json` (0600). In Phase 1 the - servers map is always empty; Phase 2 populates it from `services.d/`. +3. Render the per-agent MCP config to `$XDG_RUNTIME_DIR/sherlock/.mcp.json` (0600). In Phase 1 the servers map is always empty; Phase 2 populates it from `services.d/`. 4. Build the child argv with the agent-specific flag. -5. Build the child env: parent env minus per-agent forbids. MCPs - spawned by the agent will read tokens straight from the OS keyring - (Phase 2+). +5. Build the child env: parent env minus per-agent forbids. MCPs spawned by the agent will reach into the OS keyring (via `internal/authn.Ensure`) on their own at startup — sherlock does not pre-authenticate anything. 6. `syscall.Exec` — sherlock disappears, the agent takes its place. ## Adding a new agent diff --git a/docs/architecture.md b/docs/architecture.md index a40c17f..ae86cdc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,51 +10,55 @@ Sherlock is a per-user credential broker + agent-CLI wrapper that runs on the op ```mermaid flowchart TD - user["user shell"] -->|`sherlock copilot`
or `sherlock run copilot`| sh[sherlock CLI] + user["user shell"] -->|`sherlock copilot`| sh[sherlock CLI] sh -->|syscall.Exec| agent[copilot / claude CLI] sh -.->|writes MCP config| mcpfile[(XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json)] agent -.->|reads| mcpfile - agent -->|stdio| gitea[gitea-mcp] - agent -->|stdio| grafana[grafana-mcp] - agent -->|stdio| gssh[gssh-mcp] + agent -->|stdio| giteamcp[gitea-mcp] + agent -->|stdio| grafanamcp[grafana-mcp] + agent -->|stdio| minifluxmcp[miniflux-mcp] - gitea --> keyring[(OS keyring
Secret Service / Keychain / Cred Mgr)] - grafana --> keyring - gssh --> keyring - sh --> keyring + giteamcp -->|authn.Ensure| wallet[(OS keyring
wallet: one TokenSet per service)] + grafanamcp -->|authn.Ensure| wallet + minifluxmcp -->|authn.Ensure| wallet + sh -->|status / logout| wallet - keyring -->|OIDC refresh on demand
flock-serialised| authentik[Authentik
id.alexandru.macocian.me] + wallet -->|OAuth PKCE flow on miss
refresh on stale
flock-serialised| authentik[Authentik
gitea provider · grafana provider ·
miniflux provider · …] ``` +Each MCP authenticates against the Authentik provider for its own +service, lazily, the first time it runs. The wallet caches the +result; subsequent runs are silent until the refresh window opens. +There is no master session. + ## Components | Component | Lives at | Owns | |---|---|---| -| `sherlock` (CLI) | `cmd/sherlock/` | The only binary. `login`, `logout`, `status`, `run`, agent-name aliases (`copilot`, `claude`, …). At login: spins up a one-shot loopback HTTP listener on `127.0.0.1:6990`, runs PKCE, writes the TokenSet to the OS keyring. At spawn: looks up the agent in `internal/agent/`, renders the per-session MCP config, `exec`s the agent. | +| `sherlock` (CLI) | `cmd/sherlock/` | The only binary the operator runs. `status`, `logout []`, `run`, agent-name aliases (`copilot`, `claude`, …). At spawn: looks up the agent in `internal/agent/`, renders the per-session MCP config, `exec`s the agent. **There is no `sherlock login`** — see [auth-model.md](auth-model.md). | | `internal/agent/` | — | One Go file per supported CLI (`copilot.go`, `claude.go`, …), each registering itself via `init()`. Shared exec/env helpers live alongside. See [agents.md](agents.md). | -| `internal/authn/` | — | OIDC discovery, PKCE, one-shot Login, file-locked EnsureFresh. See [auth-model.md](auth-model.md). | -| `internal/keyring/` | — | OS keyring wrapper: `Probe` (called at every CLI start), `TokenSet` persistence. See [storage.md](storage.md). | +| `internal/authn/` | — | OAuth/OIDC primitives + the high-level `Ensure(ctx, store, service, cfg, opts)` that every MCP calls at startup. See [auth-model.md](auth-model.md). | +| `internal/keyring/` | — | OS keyring wallet, service-keyed: `Get` / `Set` / `Clear` / `List` per service. `Open()` is the only constructor; it probes the keyring and fails fast. See [storage.md](storage.md). | | `internal/mcp/` | — | Per-format MCP-config renderers (VS Code shape for Copilot, `.mcp.json` shape for Claude Code). | | `internal/xdg/` | — | Resolves `$XDG_RUNTIME_DIR/sherlock/.mcp.json` and the refresh-lock path. | -| `cmd/gitea-mcp/` (Phase 2) | — | First per-service MCP. Reads tokens directly from the keyring (via `internal/authn`); refreshes via flock-serialised EnsureFresh on 401. | +| `cmd/gitea-mcp/` (Phase 2) | — | First per-service MCP. Reads service config from `~/.config/sherlock/services.d/gitea.toml`, calls `authn.Ensure(store, "gitea", cfg, opts)` at startup, then serves MCP requests. | | `cmd/gssh-mcp/` (Phase 3) | — | Thin HTTP+WS client to the existing gssh server. No local certs. See [gssh-integration.md](gssh-integration.md). | -| `cmd/grafana-mcp/` (Phase 4) | — | Grafana tools, OIDC-federated via Authentik. | -| `cmd/sherlock-mcp/` (Phase 4) | — | The only **interactive** MCP. Exposes `oauth.consent(service)`, `auth.whoami()`, `auth.list_services()`, `auth.revoke(service)`. | +| `cmd/grafana-mcp/` (Phase 4) | — | Grafana tools, OAuth-federated via Authentik. | +| `cmd/sherlock-mcp/` (Phase 4) | — | The only **interactive** MCP. Exposes `auth.list_sessions()`, `auth.revoke(service)`, etc., so the agent can query/manage the wallet through tool calls. | -## Why there is no daemon +## Why there is no daemon, and no `sherlock login` -The original Phase 1 design had a separate `sherlock-broker` daemon (forked-child, UDS RPC, PID-file flock, idle timer). It was removed in the post-Phase-1 refactor — Decisions #9 (JSON-over-newline RPC) and #10 (forked-child broker) are both superseded. +The Phase 1 design had a separate `sherlock-broker` daemon (forked-child, UDS RPC, PID-file flock, idle timer). It was removed in the post-Phase-1 refactor — Decisions #9 (JSON-over-newline RPC) and #10 (forked-child broker) are both superseded. Then `sherlock login` itself was removed in a follow-up refactor, because preemptive authentication never matched the actual topology. Reasoning: -- The OS keyring is already the single source of truth across processes; a daemon caching it in RAM is redundant on the homelab single-user scale. -- The loopback HTTP listener for PKCE only needs to be alive during `sherlock login` (a few seconds). No reason to keep it running. -- Cross-process refresh races (multiple MCPs hitting 401 simultaneously) are solved by an exclusive `flock()` on `$XDG_RUNTIME_DIR/sherlock.refresh.lock` with the canonical "take lock → re-read → maybe refresh" pattern. -- Service-specific OAuth (Phase 2 own-oauth services like GitHub.com) gets its own explicit `sherlock auth ` command that spins up a fresh listener for the duration of the flow. +- **No daemon needed.** The OS keyring is already the single source of truth across processes. Cross-process refresh races are solved by an exclusive `flock()` on `$XDG_RUNTIME_DIR/sherlock.refresh.lock` with the canonical "take lock → re-read → maybe refresh" pattern in `internal/authn/refresh.go`. +- **No master session.** Caddy + each downstream service have their own Authentik OAuth providers with their own audiences, scopes, and consent screens. A single master token cannot satisfy all of them. Per-service tokens are the right granularity, and per-service tokens are best fetched on demand by the MCP that needs them. +- **No `sherlock login` step.** Pre-authenticating to N services up-front for the case where the user only ends up using one of them is wasted browser flows. Lazy-on-first-use is the right default; the wallet caches the rest. -What we lose: a small amount of latency overhead per refresh (libsecret D-Bus round-trip ~1ms) and the ability to handle a mid-session OAuth pop-up without an explicit command. What we gain: no daemon lifecycle, no PID files, no IPC protocol, no idle timers, ~800 LoC less, and "is sherlock-broker still running" stops being a debugging path. +What we lose: a small amount of latency overhead per refresh (libsecret D-Bus round-trip ~1ms) and the ability to handle a mid-session OAuth pop-up without a user click. What we gain: no daemon lifecycle, no PID files, no IPC protocol, no idle timers, no preemptive-login state machine, and "is sherlock-broker still running" stops being a debugging path. ## Boundaries diff --git a/docs/auth-model.md b/docs/auth-model.md index f557ad7..08cf0e2 100644 --- a/docs/auth-model.md +++ b/docs/auth-model.md @@ -2,57 +2,85 @@ How sherlock obtains and distributes credentials. -## The two layers +## Mental model: wallet of OAuth sessions -1. **Operator → Authentik.** A single OAuth 2.1 authorization-code + PKCE flow against Authentik (`id.alexandru.macocian.me`), driven by `sherlock login`. The resulting ID/access/refresh tokens land in the OS keyring (see [storage.md](storage.md)). This is the only browser flow per workstation per session. -2. **MCP → downstream service.** Each per-service MCP reads the operator's stored tokens from the keyring (via `internal/authn`) and mints a service-scoped token using one of the three grant kinds below. There is no daemon mediating this — see [architecture.md#why-there-is-no-daemon](architecture.md#why-there-is-no-daemon). +Sherlock is a wallet. Each entry is one OAuth session against one +upstream service's Authentik provider (gitea, grafana, miniflux, +invidious, …). There is **no master Authentik session**; every entry +is service-keyed. -## Grant kinds +This matches the homelab topology: caddy reverse-proxies the public +endpoints and enforces edge OIDC for browsers; each downstream service +*also* has its own Authentik OAuth client used to bind a service-side +account to the operator's Authentik identity. An MCP that wants to +call `gitea.x` needs a token issued by gitea's specific Authentik +provider — caddy + gitea both verify it. The same goes for every +other service. One master token cannot satisfy all of them. -Set per service in `~/.config/sherlock/services.d/.toml` (see [service-registry.md](service-registry.md)). +## Authentication is lazy and per-MCP -### `oidc-federated` +`sherlock login` does not exist. The operator does not preemptively +authenticate. Each MCP at startup calls -The downstream service already federates against Authentik (Gitea, Grafana, Caddy-protected apps). The MCP either: +```go +ts, err := authn.Ensure(ctx, store, "gitea", cfg, opts) +``` -- Hands the existing Authentik-issued ID token (when the service's expected audience matches), or -- Performs **RFC 8693 token exchange** against Authentik's token endpoint, swapping the operator's ID token for a service-scoped access token. +`Ensure` (in `internal/authn`) handles every case: -The MCP receives the token in an env var (e.g. `GITEA_TOKEN`) injected by sherlock at agent spawn. +1. Wallet has fresh tokens for `gitea` → return them. +2. Wallet has stale tokens with a usable refresh token → refresh under + a cross-process flock, persist, return. +3. Wallet is empty (or refresh failed) → bind the loopback listener on + `127.0.0.1:6990`, run a fresh OAuth PKCE flow against the service's + Authentik provider (opens a browser via `xdg-open`), persist, + return. -### `own-oauth` +The first time an operator runs `sherlock copilot` in a fresh +environment, the agent spawns its MCPs; each MCP triggers its own +browser flow as it needs one. Subsequent runs are silent until the +refresh window opens. -The service runs its own OAuth/OIDC stack, not federated through Authentik (think GitHub.com, a SaaS API, …). Triggered by an explicit `sherlock auth ` command: +## What lives in `services.d/.toml` (Phase 2) -- The CLI spins up a fresh loopback listener (separate from the Authentik one on :6990). -- Runs the auth-code + PKCE flow in a browser, with the callback received on that listener. -- Persists the refresh token alongside the Authentik creds, keyed under `service:` in the keyring. -- Subsequent MCP runs refresh silently from the stored refresh token. +Each MCP needs an OIDC `Config` (issuer + client ID + scopes) to pass +to `Ensure`. The operator-editable source of truth is -### `static-pat` +```toml +[service] +name = "gitea" +issuer = "https://id.alexandru.macocian.me/application/o/gitea/" +client_id = "Ig8...abc" +scopes = ["openid", "profile", "email"] +audience = "gitea.alexandru.macocian.me" # informational; aud check is on the IdP side -For services that have no OAuth at all, or where the only sane option is a long-lived PAT. The operator stores the PAT in the keyring once via `sherlock auth --pat`, and the MCP reads it from there. Avoid where possible; document why every time it's used. +[mcp] +env_var = "GITEA_TOKEN" # what sherlock injects into the agent env (Phase 2+) +``` -## Token storage - -See [storage.md](storage.md) for the full decision (OS keyring via `zalando/go-keyring`, pre-flight at startup of every CLI invocation, exit code 3 on failure). - -- Authentik creds persist in the OS keyring (Decision #8); per-service tokens follow the same pattern in Phase 2+. -- Refresh races across concurrent processes (multiple MCPs hitting 401 at once) are serialised via an exclusive `flock()` on `$XDG_RUNTIME_DIR/sherlock.refresh.lock`. The canonical "take lock → re-read keyring → maybe refresh" pattern; see `internal/authn/refresh.go`. +Sherlock-the-CLI does not read these in Phase 1 (the CLI has no +authentication subcommands). Phase 2 MCPs load their own service's +TOML at startup. See [service-registry.md](service-registry.md) for +the full schema. ## Audience binding -Per the MCP 2025-06-18 spec, downstream tokens MUST be audience-bound. The MCP never hands its tool implementation a token whose `aud` claim doesn't name the right service. This is the "confused deputy" mitigation the spec calls out. +Per the MCP 2025-06-18 spec, downstream tokens MUST be audience-bound. +Each Authentik provider issues tokens with `aud` set to its service's +canonical name. Caddy and the upstream both verify it. Sherlock never +tries to reuse one service's token for another. ## Scope minimisation -Default service registrations request read-only scopes. Write scopes require either: - -- The operator passing `--write` to `sherlock run` (Phase 5), or -- An explicit `scopes` override in the service's TOML. +Default service registrations request read-only scopes. Write scopes +require an explicit override in the service's TOML (Phase 2). ## Out of scope for sherlock - Issuing SSH certs (gssh handles this — [gssh-integration.md](gssh-integration.md)). - Multi-user / shared tenancy. Sherlock is per-operator, full stop. -- Acting as a Resource Server itself (Phase 4's `sherlock-mcp` is a stdio MCP, not HTTP — the agent CLI talks to it over a pipe, no inbound auth). +- Acting as a Resource Server itself. +- RFC 8693 token exchange. Tried during planning; rejected because + each service's Authentik provider has its own consent screen and + scope set and the exchange story across them is more brittle than + just running N separate PKCE flows lazily. diff --git a/docs/storage.md b/docs/storage.md index 1365a58..264ccb7 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -7,15 +7,15 @@ How sherlock persists secrets and runtime state. | Topic | Decision | |---|---| | Token storage | OS keyring via [`github.com/zalando/go-keyring`](https://github.com/zalando/go-keyring). No on-disk credential files, no `age` blobs, no plaintext. | +| Wallet shape | One `TokenSet` per service (`gitea`, `grafana`, `miniflux`, …). Tracked via a sidecar `services-index` entry so `Store.List()` works on every backend without OS-specific search APIs. | | Pre-flight | The only constructor is `keyring.Open()`, which probes the keyring before returning a Store. There is no probe-less escape hatch. A missing/locked keyring returns `*UnavailableError` (with a populated `Hint` field) and exits 3. | -| Service name | `sherlock` for real tokens; `sherlock-preflight` for the probe sentinel. | -| Account key | The Authentik `sub` claim once logged in, `default` before. | -| Runtime files | `$XDG_RUNTIME_DIR/sherlock.sock`, `sherlock.pid`, `sherlock.log`, `sherlock/.mcp.json`. All `0600` (files) / `0700` (dirs). | +| Keyring service ns | `sherlock` for all real entries; `sherlock-preflight` for the probe sentinel. Per-service tokens are accounts of the form `service:`; the index is account `services-index`. | +| Runtime files | `$XDG_RUNTIME_DIR/sherlock/.mcp.json` (0600), `$XDG_RUNTIME_DIR/sherlock.refresh.lock` (0600, flock anchor for cross-process refreshes). No socket, no PID file, no daemon log. | | Config files | `$XDG_CONFIG_HOME/sherlock/services.d/*.toml` (operator-registered services, Phase 2+). Agent integrations are compiled in — see [agents.md](agents.md). | ## TokenSet -What we store under the `sherlock` keyring service: +What sherlock stores per service entry: ```go type TokenSet struct { @@ -24,16 +24,39 @@ type TokenSet struct { RefreshToken string IDExpiresAt time.Time RefreshExpAt time.Time - Issuer string + Issuer string // for refresh: full OIDC issuer URL + ClientID string // for refresh: OAuth client ID + Scopes []string // for refresh: scopes to re-request Subject string Email string Name string } ``` -Serialized as a single JSON blob — the keyring exposes one secret per -`(service, account)` pair and we don't want to round-trip multiple -secrets. +Serialized as a single JSON blob per service entry — the keyring +exposes one secret per `(service, account)` pair and we don't want to +round-trip multiple secrets per session. + +`Issuer` + `ClientID` + `Scopes` are persisted alongside the tokens so +refresh and re-login work from any process, with no dependency on the +shell environment that originally created the entry. + +## Wallet API + +`keyring.Store` is service-keyed: + +```go +type Store interface { + Get(service string) (TokenSet, error) // ErrNoTokens if missing + Set(service string, ts TokenSet) error // also updates the index + Clear(service string) error // also updates the index + List() ([]string, error) // names sorted +} +``` + +`Get` / `Set` / `Clear` go straight to the OS keyring under +`(service="sherlock", account="service:")`. `List` reads the +sidecar `services-index` entry; `Set`/`Clear` keep it in sync. ## Pre-flight semantics @@ -49,7 +72,8 @@ secrets. is the type-predicate for branching. CLI behaviour: any failure prints the error (which already includes -the hint) and exits with code `3`. +the hint) and exits with code `3`. MCPs inherit the same behaviour +because they construct their Store via the same `keyring.Open()`. ## Platform notes diff --git a/internal/authn/authn.go b/internal/authn/authn.go index 2cb7c2a..c91184f 100644 --- a/internal/authn/authn.go +++ b/internal/authn/authn.go @@ -1,17 +1,17 @@ -// Package authn implements the operator-facing OAuth/OIDC flow against -// Authentik: discovery, PKCE, loopback redirect on a fixed port, -// ID-token verification, and refresh. +// Package authn implements the OAuth/OIDC primitives sherlock and its +// MCPs use against Authentik: discovery, PKCE, loopback redirect on a +// fixed port, ID-token verification, refresh, and the high-level +// Ensure helper that ties them all together. // // Decision #11: redirect URI is fixed at http://127.0.0.1:6990/callback -// (loopback, IANA-unassigned port). The Authentik OAuth provider -// configuration is deferred — Login surfaces a clean "not configured" -// error until SHERLOCK_AUTHENTIK_ISSUER / SHERLOCK_AUTHENTIK_CLIENT_ID -// are set in the operator's environment. +// (loopback, IANA-unassigned port). Each Authentik provider sherlock +// talks to must whitelist this URI. // -// Refactored post-Phase-1: no daemon, no broker, no in-process -// state-table; both Login and EnsureFresh are one-shot functions -// safe to call across concurrent CLI invocations (refresh races are -// resolved via a file lock; see refresh.go). +// There is no user-facing "sherlock login" step. Each MCP calls +// Ensure(store, service, cfg) at startup; the browser pops only if +// the wallet is empty or its refresh token has expired. The wallet +// is the OS keyring; refresh races across processes are serialised +// via a flock on $XDG_RUNTIME_DIR/sherlock.refresh.lock. package authn import ( diff --git a/internal/authn/ensure.go b/internal/authn/ensure.go new file mode 100644 index 0000000..901c078 --- /dev/null +++ b/internal/authn/ensure.go @@ -0,0 +1,86 @@ +//go:build unix + +package authn + +import ( + "context" + "errors" + + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring" +) + +// EnsureOptions tunes Ensure for tests and for MCPs that want to +// override the browser-open behaviour. +type EnsureOptions struct { + // LockPath is the cross-process flock file used to serialise + // refreshes (and, in the future, fresh OAuth flows). Required. + LockPath string + + // Login lets the operator know what URL their browser is opening. + // Defaults to a no-op; the CLI/MCP wrapper sets this to xdg-open. + OnAuthURL func(url string) + + // Discoverer overrides OIDC discovery; mostly useful in tests. + Discoverer Discoverer +} + +// Ensure returns a TokenSet for service that is valid right now, +// taking whatever path is necessary: +// +// - If the wallet already has fresh tokens, returns them unchanged. +// - If the wallet has stale tokens with a usable refresh token, +// refreshes (under the flock) and persists the result. +// - If the wallet has no tokens (or refresh failed), runs a fresh +// OAuth PKCE flow against cfg and persists the result. +// +// MCPs call this at startup. The first call ever for a service may +// block on the operator clicking through a browser; subsequent calls +// are silent until the refresh window opens. +// +// Ensure is the canonical entry point for everything that needs a +// token. There is intentionally no separate "log me in" step the +// operator has to run first; sherlock has no notion of "the user +// pre-authorized the wallet". Authentication happens when and where +// it's needed, no sooner. +func Ensure(ctx context.Context, store keyring.Store, service string, cfg Config, opts EnsureOptions) (keyring.TokenSet, error) { + if service == "" { + return keyring.TokenSet{}, errors.New("authn: Ensure requires a service name") + } + if opts.LockPath == "" { + return keyring.TokenSet{}, errors.New("authn: Ensure requires LockPath") + } + if !cfg.Configured() { + return keyring.TokenSet{}, ErrNotConfigured + } + if opts.OnAuthURL == nil { + opts.OnAuthURL = func(string) {} + } + + // Try the refresh path first. If we have any tokens stored at all, + // EnsureFresh either returns them as-is (still fresh) or refreshes. + ts, err := EnsureFresh(ctx, store, service, EnsureFreshOptions{ + LockPath: opts.LockPath, + Discoverer: opts.Discoverer, + }) + switch { + case err == nil: + return ts, nil + case errors.Is(err, keyring.ErrNoTokens), errors.Is(err, ErrNoRefreshToken): + // Fall through to a fresh OAuth flow. + default: + // Any other error (network failure mid-refresh, revoked + // refresh token, …) also forces a fresh flow. + } + + res, err := Login(ctx, cfg, LoginOptions{ + OnAuthURL: opts.OnAuthURL, + Discoverer: opts.Discoverer, + }) + if err != nil { + return keyring.TokenSet{}, err + } + if err := store.Set(service, res.Tokens); err != nil { + return res.Tokens, err + } + return res.Tokens, nil +} diff --git a/internal/authn/ensure_test.go b/internal/authn/ensure_test.go new file mode 100644 index 0000000..5122eb4 --- /dev/null +++ b/internal/authn/ensure_test.go @@ -0,0 +1,107 @@ +//go:build unix + +package authn + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring" + fakekeyring "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring/fake" +) + +func TestEnsure_FastPath_NoOAuthFlow(t *testing.T) { + store := fakekeyring.New() + _ = store.Set("gitea", keyring.TokenSet{ + IDToken: "fresh", + IDExpiresAt: time.Now().Add(time.Hour), + Issuer: "x", + ClientID: "y", + }) + // cfg.Configured() must hold or Ensure refuses up front; supply + // dummy values that are never actually contacted. + cfg := Config{Issuer: "https://example.test", ClientID: "x"} + browserCalled := false + ts, err := Ensure(context.Background(), store, "gitea", cfg, EnsureOptions{ + LockPath: t.TempDir() + "/lock", + OnAuthURL: func(string) { browserCalled = true }, + }) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if ts.IDToken != "fresh" { + t.Fatalf("expected stored token; got %+v", ts) + } + if browserCalled { + t.Fatal("Ensure should not open a browser when wallet is fresh") + } +} + +func TestEnsure_EmptyWallet_RunsOAuthFlow(t *testing.T) { + stub := newStubAuthentik(t) + store := fakekeyring.New() + cfg := Config{ + Issuer: stub.srv.URL, + ClientID: "test-client", + Scopes: []string{"openid", "profile", "email"}, + LoopbackAddrOverride: "127.0.0.1:0", + } + opts := EnsureOptions{ + LockPath: t.TempDir() + "/lock", + OnAuthURL: func(u string) { + // drive the redirect just like TestLogin_HappyPath + resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }}).Get(u) + if err != nil { + t.Errorf("browser stub: %v", err) + return + } + defer func() { _ = resp.Body.Close() }() + cb, err := http.Get(resp.Header.Get("Location")) + if err != nil { + t.Errorf("loopback: %v", err) + return + } + _, _ = io.Copy(io.Discard, cb.Body) + _ = cb.Body.Close() + }, + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ts, err := Ensure(ctx, store, "gitea", cfg, opts) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if ts.IDToken == "" { + t.Fatalf("expected fresh tokens; got %+v", ts) + } + // The wallet should now have the entry persisted. + stored, err := store.Get("gitea") + if err != nil { + t.Fatalf("post-Ensure Get: %v", err) + } + if stored.IDToken != ts.IDToken { + t.Fatal("Ensure did not persist tokens to the wallet") + } + names, _ := store.List() + if len(names) != 1 || names[0] != "gitea" { + t.Fatalf("wallet list = %v", names) + } +} + +func TestEnsure_NotConfigured(t *testing.T) { + store := fakekeyring.New() + _, err := Ensure(context.Background(), store, "gitea", Config{}, EnsureOptions{LockPath: t.TempDir() + "/lock"}) + if err != ErrNotConfigured { + t.Fatalf("err = %v, want ErrNotConfigured", err) + } +} + +// ensure the imports above stay tied to actual symbols once we cut +// the high-level path later. +var _ = fmt.Sprintf diff --git a/internal/authn/login.go b/internal/authn/login.go index af01618..e9673a6 100644 --- a/internal/authn/login.go +++ b/internal/authn/login.go @@ -40,16 +40,20 @@ type LoginOptions struct { OnAuthURL func(url string) } -// Login runs the full Authentik PKCE flow synchronously: bind a -// loopback listener, generate state + PKCE, build the auth URL and -// hand it to OnAuthURL, then block until the browser redirects to -// /callback (or ctx is cancelled). On success it verifies the ID -// token and returns the populated TokenSet. +// Login runs the full OAuth PKCE flow synchronously: bind a loopback +// listener on cfg.LoopbackAddr (default :6990), generate state + +// PKCE, build the auth URL and hand it to OnAuthURL, then block +// until the browser redirects to /callback (or ctx is cancelled). +// On success it verifies the ID token and returns the populated +// TokenSet. +// +// Login does not persist the result. Callers that want a stored, +// kept-fresh token should use Ensure instead. // // Login is safe to call from multiple processes only one at a time — // the loopback port is a global resource. A second concurrent Login -// will fail with EADDRINUSE; that's the right behavior (the operator -// is doing two `sherlock login` invocations). +// will fail with EADDRINUSE; we surface that verbatim so the +// operator (or the second MCP) can retry. func Login(ctx context.Context, cfg Config, opts LoginOptions) (Result, error) { if !cfg.Configured() { return Result{}, ErrNotConfigured diff --git a/internal/authn/login_test.go b/internal/authn/login_test.go index d29006c..79fe918 100644 --- a/internal/authn/login_test.go +++ b/internal/authn/login_test.go @@ -23,6 +23,7 @@ import ( "github.com/go-jose/go-jose/v4/jwt" "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring" + fakekeyring "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring/fake" ) // stubAuthentik signs ES256 ID tokens and returns them through a real @@ -260,14 +261,14 @@ func TestIDTokenVerifies_AgainstStub(t *testing.T) { } func TestEnsureFresh_FastPath(t *testing.T) { - store := newMemStore() - store.tokens = keyring.TokenSet{ + store := fakekeyring.New() + _ = store.Set("test", keyring.TokenSet{ IDToken: "ok", IDExpiresAt: time.Now().Add(time.Hour), Issuer: "x", ClientID: "y", - } - ts, err := EnsureFresh(context.Background(), store, EnsureFreshOptions{LockPath: t.TempDir() + "/lock"}) + }) + ts, err := EnsureFresh(context.Background(), store, "test", EnsureFreshOptions{LockPath: t.TempDir() + "/lock"}) if err != nil { t.Fatalf("EnsureFresh: %v", err) } @@ -277,55 +278,23 @@ func TestEnsureFresh_FastPath(t *testing.T) { } func TestEnsureFresh_NoTokens(t *testing.T) { - store := newMemStore() - _, err := EnsureFresh(context.Background(), store, EnsureFreshOptions{LockPath: t.TempDir() + "/lock"}) + store := fakekeyring.New() + _, err := EnsureFresh(context.Background(), store, "test", EnsureFreshOptions{LockPath: t.TempDir() + "/lock"}) if err != keyring.ErrNoTokens { t.Fatalf("err = %v, want ErrNoTokens", err) } } func TestEnsureFresh_NeedsRefreshButNoRefreshToken(t *testing.T) { - store := newMemStore() - store.tokens = keyring.TokenSet{ + store := fakekeyring.New() + _ = store.Set("test", keyring.TokenSet{ IDToken: "stale", IDExpiresAt: time.Now().Add(-time.Minute), Issuer: "x", ClientID: "y", - } - _, err := EnsureFresh(context.Background(), store, EnsureFreshOptions{LockPath: t.TempDir() + "/lock"}) + }) + _, err := EnsureFresh(context.Background(), store, "test", EnsureFreshOptions{LockPath: t.TempDir() + "/lock"}) if err != ErrNoRefreshToken { t.Fatalf("err = %v, want ErrNoRefreshToken", err) } } - -type memStore struct { - mu sync.Mutex - tokens keyring.TokenSet - cleared bool -} - -func newMemStore() *memStore { return &memStore{} } - -func (m *memStore) GetAuthentikTokens() (keyring.TokenSet, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.tokens.Empty() { - return keyring.TokenSet{}, keyring.ErrNoTokens - } - return m.tokens, nil -} - -func (m *memStore) SetAuthentikTokens(ts keyring.TokenSet) error { - m.mu.Lock() - defer m.mu.Unlock() - m.tokens = ts - return nil -} - -func (m *memStore) ClearAuthentikTokens() error { - m.mu.Lock() - defer m.mu.Unlock() - m.tokens = keyring.TokenSet{} - m.cleared = true - return nil -} diff --git a/internal/authn/refresh.go b/internal/authn/refresh.go index 9665bd1..73b5b99 100644 --- a/internal/authn/refresh.go +++ b/internal/authn/refresh.go @@ -38,16 +38,19 @@ type EnsureFreshOptions struct { Clock func() time.Time } -// EnsureFresh returns the stored TokenSet, refreshing it via Authentik -// if the ID token has < RefreshSkew left. Cross-process safe: takes -// an exclusive flock on opts.LockPath, re-reads the keyring after -// acquiring the lock, and skips the refresh if a concurrent process -// already rotated the tokens. +// EnsureFresh returns the stored TokenSet for service, refreshing it +// against its OAuth issuer if the ID token has < RefreshSkew left. +// Cross-process safe: takes an exclusive flock on opts.LockPath, +// re-reads the keyring after acquiring the lock, and skips the +// refresh if a concurrent process already rotated the tokens. // // The lockfile is created with mode 0600 and never removed — its // only role is to anchor the flock; multiple sherlock invocations // across an operator's lifetime share it. -func EnsureFresh(ctx context.Context, store keyring.Store, opts EnsureFreshOptions) (keyring.TokenSet, error) { +// +// Callers that want full "ensure I have a token, even if it means +// starting an OAuth flow" semantics should use Ensure instead. +func EnsureFresh(ctx context.Context, store keyring.Store, service string, opts EnsureFreshOptions) (keyring.TokenSet, error) { if opts.LockPath == "" { return keyring.TokenSet{}, errors.New("authn: EnsureFresh requires LockPath") } @@ -58,7 +61,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, opts EnsureFreshOptio opts.Clock = time.Now } - ts, err := store.GetAuthentikTokens() + ts, err := store.Get(service) if err != nil { return keyring.TokenSet{}, err } @@ -86,7 +89,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, opts EnsureFreshOptio // Re-read after acquiring the lock. A concurrent process may have // refreshed for us while we were waiting on flock. - ts, err = store.GetAuthentikTokens() + ts, err = store.Get(service) if err != nil { return ts, err } @@ -101,7 +104,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, opts EnsureFreshOptio if err != nil { return ts, err } - if err := store.SetAuthentikTokens(fresh); err != nil { + if err := store.Set(service, fresh); err != nil { return ts, fmt.Errorf("authn: persist refreshed tokens: %w", err) } return fresh, nil diff --git a/internal/keyring/fake/fake.go b/internal/keyring/fake/fake.go index 8e588ed..916013a 100644 --- a/internal/keyring/fake/fake.go +++ b/internal/keyring/fake/fake.go @@ -3,42 +3,52 @@ package fakekeyring import ( + "slices" "sync" "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring" ) // New returns a fresh in-memory Store. -func New() *Store { return &Store{} } +func New() *Store { return &Store{tokens: map[string]keyring.TokenSet{}} } // Store is a goroutine-safe in-memory Store. type Store struct { mu sync.Mutex - tokens keyring.TokenSet - set bool + tokens map[string]keyring.TokenSet } -func (s *Store) GetAuthentikTokens() (keyring.TokenSet, error) { +func (s *Store) Get(service string) (keyring.TokenSet, error) { s.mu.Lock() defer s.mu.Unlock() - if !s.set { + ts, ok := s.tokens[service] + if !ok { return keyring.TokenSet{}, keyring.ErrNoTokens } - return s.tokens, nil + return ts, nil } -func (s *Store) SetAuthentikTokens(ts keyring.TokenSet) error { +func (s *Store) Set(service string, ts keyring.TokenSet) error { s.mu.Lock() defer s.mu.Unlock() - s.tokens = ts - s.set = true + s.tokens[service] = ts return nil } -func (s *Store) ClearAuthentikTokens() error { +func (s *Store) Clear(service string) error { s.mu.Lock() defer s.mu.Unlock() - s.tokens = keyring.TokenSet{} - s.set = false + delete(s.tokens, service) return nil } + +func (s *Store) List() ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + names := make([]string, 0, len(s.tokens)) + for n := range s.tokens { + names = append(names, n) + } + slices.Sort(names) + return names, nil +} diff --git a/internal/keyring/fake/fake_test.go b/internal/keyring/fake/fake_test.go index 145ab32..6ed8edf 100644 --- a/internal/keyring/fake/fake_test.go +++ b/internal/keyring/fake/fake_test.go @@ -1,38 +1,39 @@ package fakekeyring import ( - "errors" + "slices" "testing" - "time" "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring" ) -func TestRoundTrip(t *testing.T) { +func TestFake_RoundTrip(t *testing.T) { s := New() - if _, err := s.GetAuthentikTokens(); !errors.Is(err, keyring.ErrNoTokens) { - t.Fatalf("expected ErrNoTokens, got %v", err) + if _, err := s.Get("gitea"); err != keyring.ErrNoTokens { + t.Fatalf("Get on empty: want ErrNoTokens, got %v", err) } - want := keyring.TokenSet{ - IDToken: "jwt.here", - Subject: "alex", - Issuer: "https://id.example/", - IDExpiresAt: time.Now().Add(time.Hour).UTC().Truncate(time.Second), + if err := s.Set("gitea", keyring.TokenSet{IDToken: "g"}); err != nil { + t.Fatal(err) } - if err := s.SetAuthentikTokens(want); err != nil { - t.Fatalf("Set: %v", err) + if err := s.Set("grafana", keyring.TokenSet{IDToken: "x"}); err != nil { + t.Fatal(err) } - got, err := s.GetAuthentikTokens() + got, err := s.Get("gitea") + if err != nil || got.IDToken != "g" { + t.Fatalf("Get gitea: %v %+v", err, got) + } + names, err := s.List() if err != nil { - t.Fatalf("Get: %v", err) + t.Fatal(err) } - if got.IDToken != want.IDToken || got.Subject != want.Subject { - t.Fatalf("got %+v", got) + if !slices.Equal(names, []string{"gitea", "grafana"}) { + t.Fatalf("List = %v", names) } - if err := s.ClearAuthentikTokens(); err != nil { - t.Fatalf("Clear: %v", err) + if err := s.Clear("gitea"); err != nil { + t.Fatal(err) } - if _, err := s.GetAuthentikTokens(); !errors.Is(err, keyring.ErrNoTokens) { - t.Fatalf("after clear expected ErrNoTokens, got %v", err) + names, _ = s.List() + if !slices.Equal(names, []string{"grafana"}) { + t.Fatalf("after clear: %v", names) } } diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index 84b5151..9b06d71 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -4,11 +4,18 @@ // Linux, Keychain on macOS, Credential Manager on Windows). No // plaintext or age-encrypted blobs on disk. // -// Open is the only public way to obtain a Store; it probes the OS +// Sherlock holds a wallet of OAuth sessions — one TokenSet per +// downstream service the operator has authenticated to (gitea, +// grafana, miniflux, …). There is no "master" Authentik session; +// every entry is service-keyed. MCPs that need a token call +// internal/authn.Ensure with their service name, which goes through +// this package. +// +// Open is the only public way to obtain a Store. It probes the OS // keyring and fails fast with platform-specific guidance if the -// keyring is missing or locked. There is deliberately no escape hatch -// that constructs a Store without probing — without the keyring, -// sherlock would silently lose refresh tokens on every command. +// keyring is missing or locked. There is no escape hatch that skips +// the probe — without the keyring, sherlock would silently lose +// refresh tokens on every command. package keyring import ( @@ -16,13 +23,16 @@ import ( "errors" "fmt" "runtime" + "slices" "time" gokeyring "github.com/zalando/go-keyring" ) // Service is the keyring "service" namespace under which sherlock -// stores secrets. Per-key labels live as untyped constants below. +// stores secrets. Per-service token sets are stored under per-name +// account labels; the index of registered names lives under +// indexAccount so Store.List can enumerate without OS-specific search. const Service = "sherlock" const ( @@ -30,7 +40,14 @@ const ( probeAccount = "probe" probeValue = "ok" - keyAuthentikTokens = "authentik-tokens" + // tokenAccountPrefix gives every per-service TokenSet a distinct + // keyring "account" without colliding with the index entry. + tokenAccountPrefix = "service:" + + // indexAccount holds a JSON array of service names with stored + // tokens, so Store.List() works on every backend without depending + // on libsecret-search / SecItemCopyMatching / etc. + indexAccount = "services-index" ) // UnavailableError is returned by Open when the OS keyring is missing @@ -57,13 +74,10 @@ func IsUnavailable(err error) bool { return errors.As(err, &u) } -// newUnavailable wraps cause with the per-OS remediation hint. func newUnavailable(cause error) *UnavailableError { return &UnavailableError{Cause: cause, Hint: hintForGOOS()} } -// hintForGOOS returns a single-line, platform-aware remediation hint. -// Internal — exposed only through UnavailableError.Hint. func hintForGOOS() string { switch runtime.GOOS { case "linux": @@ -81,14 +95,8 @@ func hintForGOOS() string { // (write+read+delete of a sentinel value) and returns the live Store // on success, or *UnavailableError on any failure. // -// We deliberately do not expose a constructor that skips the probe. -// Every production code path needs the fail-fast guarantee, and an -// "I know what I'm doing" escape hatch would invite real bugs where -// refresh tokens silently vanish into a dead keyring. Tests that need -// a Store without a real keyring use the in-memory keyring/fake.Store. -// -// Safe to call repeatedly. Each call performs a fresh probe; the -// returned Store carries no per-instance state. +// Safe to call repeatedly. The returned Store carries no per-instance +// state. func Open() (Store, error) { if err := probe(); err != nil { return nil, err @@ -115,10 +123,10 @@ func probe() error { return nil } -// TokenSet is what sherlock persists after a successful Authentik -// flow. ClientID + Scopes are recorded alongside the tokens so that -// background refresh works without depending on environment variables -// set in the original login shell. +// TokenSet is what sherlock persists per service after a successful +// OAuth flow. ClientID + Scopes + Issuer are recorded alongside the +// tokens so that background refresh works without re-reading any +// external configuration. type TokenSet struct { IDToken string `json:"id_token"` AccessToken string `json:"access_token,omitempty"` @@ -136,53 +144,148 @@ type TokenSet struct { // Empty reports whether the TokenSet carries no usable session. func (t TokenSet) Empty() bool { return t.IDToken == "" && t.RefreshToken == "" } -// Store is the typed wrapper sherlock uses. Implementations must be -// safe for concurrent use. +// Store is the typed wallet sherlock uses. Implementations must be +// safe for concurrent use (the OS keyring backend serialises writes +// internally; the in-memory fake guards with a mutex). +// +// The service argument is a short name like "gitea" or "grafana". +// Callers must not embed colons or other separators — the package +// scopes everything under tokenAccountPrefix internally. type Store interface { - GetAuthentikTokens() (TokenSet, error) - SetAuthentikTokens(TokenSet) error - ClearAuthentikTokens() error + // Get returns the TokenSet for service, or ErrNoTokens if no entry + // exists. + Get(service string) (TokenSet, error) + // Set persists ts under service, atomically updating the + // registered-services index. + Set(service string, ts TokenSet) error + // Clear removes the entry for service. Returns nil if nothing was + // stored. + Clear(service string) error + // List returns the names of every service with stored tokens. + List() ([]string, error) } -// ErrNoTokens is returned by GetAuthentikTokens when nothing is stored. -var ErrNoTokens = errors.New("keyring: no authentik tokens stored") +// ErrNoTokens is returned by Get when nothing is stored for a service. +var ErrNoTokens = errors.New("keyring: no tokens stored") // realStore is the production Store backed by zalando/go-keyring. // Unexported on purpose — callers go through Open. type realStore struct{} -func (realStore) GetAuthentikTokens() (TokenSet, error) { - raw, err := gokeyring.Get(Service, keyAuthentikTokens) +func (realStore) Get(service string) (TokenSet, error) { + if err := validateName(service); err != nil { + return TokenSet{}, err + } + raw, err := gokeyring.Get(Service, tokenAccountPrefix+service) if err != nil { if errors.Is(err, gokeyring.ErrNotFound) { return TokenSet{}, ErrNoTokens } - return TokenSet{}, fmt.Errorf("keyring: get authentik tokens: %w", err) + return TokenSet{}, fmt.Errorf("keyring: get %s: %w", service, err) } var ts TokenSet if err := json.Unmarshal([]byte(raw), &ts); err != nil { - return TokenSet{}, fmt.Errorf("keyring: decode tokens: %w", err) + return TokenSet{}, fmt.Errorf("keyring: decode %s: %w", service, err) } return ts, nil } -func (realStore) SetAuthentikTokens(ts TokenSet) error { +func (s realStore) Set(service string, ts TokenSet) error { + if err := validateName(service); err != nil { + return err + } b, err := json.Marshal(ts) if err != nil { - return fmt.Errorf("keyring: encode tokens: %w", err) + return fmt.Errorf("keyring: encode %s: %w", service, err) } - if err := gokeyring.Set(Service, keyAuthentikTokens, string(b)); err != nil { - return fmt.Errorf("keyring: set authentik tokens: %w", err) + if err := gokeyring.Set(Service, tokenAccountPrefix+service, string(b)); err != nil { + return fmt.Errorf("keyring: set %s: %w", service, err) + } + return s.addToIndex(service) +} + +func (s realStore) Clear(service string) error { + if err := validateName(service); err != nil { + return err + } + if err := gokeyring.Delete(Service, tokenAccountPrefix+service); err != nil { + if !errors.Is(err, gokeyring.ErrNotFound) { + return fmt.Errorf("keyring: delete %s: %w", service, err) + } + } + return s.removeFromIndex(service) +} + +func (realStore) List() ([]string, error) { + return readIndex() +} + +func (s realStore) addToIndex(service string) error { + names, err := readIndex() + if err != nil { + return err + } + if slices.Contains(names, service) { + return nil + } + names = append(names, service) + slices.Sort(names) + return writeIndex(names) +} + +func (s realStore) removeFromIndex(service string) error { + names, err := readIndex() + if err != nil { + return err + } + idx := slices.Index(names, service) + if idx < 0 { + return nil + } + names = slices.Delete(names, idx, idx+1) + if len(names) == 0 { + if err := gokeyring.Delete(Service, indexAccount); err != nil && !errors.Is(err, gokeyring.ErrNotFound) { + return fmt.Errorf("keyring: delete index: %w", err) + } + return nil + } + return writeIndex(names) +} + +func readIndex() ([]string, error) { + raw, err := gokeyring.Get(Service, indexAccount) + if err != nil { + if errors.Is(err, gokeyring.ErrNotFound) { + return nil, nil + } + return nil, fmt.Errorf("keyring: get index: %w", err) + } + var names []string + if err := json.Unmarshal([]byte(raw), &names); err != nil { + return nil, fmt.Errorf("keyring: decode index: %w", err) + } + return names, nil +} + +func writeIndex(names []string) error { + b, err := json.Marshal(names) + if err != nil { + return fmt.Errorf("keyring: encode index: %w", err) + } + if err := gokeyring.Set(Service, indexAccount, string(b)); err != nil { + return fmt.Errorf("keyring: set index: %w", err) } return nil } -func (realStore) ClearAuthentikTokens() error { - if err := gokeyring.Delete(Service, keyAuthentikTokens); err != nil { - if errors.Is(err, gokeyring.ErrNotFound) { - return nil +func validateName(service string) error { + if service == "" { + return errors.New("keyring: empty service name") + } + for _, r := range service { + if r == ':' || r == '/' || r == ' ' { + return fmt.Errorf("keyring: invalid character %q in service name %q", r, service) } - return fmt.Errorf("keyring: delete authentik tokens: %w", err) } return nil } diff --git a/plan.md b/plan.md index 190696c..c585a3d 100644 --- a/plan.md +++ b/plan.md @@ -229,19 +229,20 @@ Reuses the existing gssh server (`gssh.alexandru.macocian.me`) — sherlock does - **Name:** `sherlock`. (Replaces "sherlock" everywhere — `sherlock-broker`, `sherlock-mcp`, `sherlock copilot …`, etc.) - **Repo location:** new repo `Charlie/sherlock`, cloned to `~/Dev/charlie/sherlock/` (sibling of the existing per-app stack repos like `gitea/`, `runners/`, etc.). Added to `docs/inventory.md` under a new "Operator tooling" section and linked from `project-charlie/README.md`. - **Language:** Go. Single static binary `cmd/sherlock/`; best-in-class OAuth/OIDC libs (`golang.org/x/oauth2`, `coreos/go-oidc`); official MCP Go SDK; native Unix primitives (flock, syscall.Exec). -- **Phase 2 Gitea auth model:** the gitea-mcp uses an OIDC token *as the operator* via Authentik. Every MCP call shows up under the operator in Gitea's audit log. No dedicated `sherlock` service account. -- **Service-registry format:** TOML, under `~/.config/sherlock/services.d/*.toml`. Loaded at the start of each CLI invocation (no daemon to hot-reload). +- **Phase 2 Gitea auth model:** the gitea-mcp uses an OIDC token *as the operator* via Authentik's gitea provider. Every MCP call shows up under the operator in Gitea's audit log. No dedicated `sherlock` service account. +- **Service-registry format:** TOML, under `~/.config/sherlock/services.d/*.toml`. Loaded by each MCP at startup (the sherlock CLI never reads them in Phase 1). - **SSH backend:** reuse the existing gssh server. `gssh-mcp` is a thin JWT-authenticated HTTP+WS client; no local SSH-cert work in sherlock. See [docs/gssh-integration.md](docs/gssh-integration.md). - **Wrapper extensibility:** agent-agnostic. Each supported agent CLI (Copilot, Claude Code, Aider, …) is a single Go file under `internal/agent/` that registers itself with the package via `init()`. `sherlock copilot` and `sherlock claude` are sugar for `sherlock run copilot` / `sherlock run claude`. Adding a new agent is a small code change, by design — per-CLI quirks (auth subcommands, MCP config schema, env-var quirks) need a real code home. The TOML-overlay design from the Phase 0 plan was tried in Phase 1 and dropped. See [docs/agents.md](docs/agents.md). -- **Docs convention:** `README.md` is TOC only; every topic lives as its own file under `docs/`. New concerns get new files, never appended sections. -- **Token persistence:** OS keyring via `zalando/go-keyring`. The only public constructor is `keyring.Open()` — it probes the keyring (write+read+delete of a sentinel) and returns the live Store or an `*UnavailableError` whose `Hint` field carries platform-specific remediation. Fails fast with exit code 3 if Secret Service / Keychain / Credential Manager is unavailable. There is no probe-less escape hatch — the API itself enforces the fail-fast invariant. See [docs/storage.md](docs/storage.md). -- **Loopback redirect port:** `127.0.0.1:6990` (unassigned per IANA). Bound only for the duration of `sherlock login`. The actual Authentik OAuth2 provider config is deferred — sherlock ships with empty defaults and `sherlock login` returns `not_configured` until `SHERLOCK_AUTHENTIK_ISSUER` / `SHERLOCK_AUTHENTIK_CLIENT_ID` are set. End-to-end flow is exercised by an integration test against a stub Authentik (`internal/authn/login_test.go`). -- **No daemon (reverses Phase 1 #9 + #10):** the Phase 1 design had a `sherlock-broker` daemon with UDS RPC (`docs/rpc.md`), JSON-over-newline framing, fork+setsid lifecycle, PID-file flock, and idle-timeout watcher. It was scrapped in the post-Phase-1 refactor — see [docs/architecture.md#why-there-is-no-daemon](docs/architecture.md#why-there-is-no-daemon). Multi-process refresh races are serialised via an exclusive `flock()` on `$XDG_RUNTIME_DIR/sherlock.refresh.lock` in `internal/authn/refresh.go`. Net: -800 LoC, no `internal/{broker,socket,rpc}/` packages, no `cmd/sherlock-broker/` binary. +- **Docs convention:** `README.md` is TOC only; every topic lives as its own file under `docs/`. New concerns get new files, never appended sections. Diagrams are Mermaid, never ASCII art. +- **Token persistence (wallet model):** OS keyring via `zalando/go-keyring`. Sherlock holds a wallet of OAuth sessions — one `TokenSet` per service (gitea, grafana, miniflux, …), tracked by a sidecar `services-index` entry so `Store.List()` works on every backend. The only public constructor is `keyring.Open()` — it probes the keyring and returns the live Store or an `*UnavailableError` whose `Hint` field carries platform-specific remediation. Fails fast with exit code 3. There is no probe-less escape hatch. See [docs/storage.md](docs/storage.md). +- **Loopback redirect port:** `127.0.0.1:6990` (unassigned per IANA). Bound only for the duration of an `authn.Login` call (i.e. when an MCP triggers a first-time PKCE flow). Each Authentik provider sherlock talks to must whitelist this URI. End-to-end flow is exercised by an integration test against a stub Authentik (`internal/authn/login_test.go`). +- **No daemon (reverses Phase 1 #9 + #10):** the Phase 1 design had a `sherlock-broker` daemon with UDS RPC, JSON-over-newline framing, fork+setsid lifecycle, PID-file flock, and idle-timeout watcher. It was scrapped — see [docs/architecture.md#why-there-is-no-daemon-and-no-sherlock-login](docs/architecture.md#why-there-is-no-daemon-and-no-sherlock-login). Multi-process refresh races are serialised via an exclusive `flock()` on `$XDG_RUNTIME_DIR/sherlock.refresh.lock` in `internal/authn/refresh.go`. +- **No `sherlock login` (reverses earlier Phase 1 plan):** preemptive authentication doesn't match the topology. Caddy + each downstream service each have their own Authentik OAuth providers with their own audiences, scopes, and consent screens; one master session can't satisfy all of them. Authentication is lazy and per-MCP: each MCP at startup calls `authn.Ensure(store, "", cfg, opts)`, which reads the wallet, refreshes if stale, or runs a fresh PKCE flow on miss. The sherlock CLI surface is reduced to `status`, `logout []`, `` / `run `, `version`, `help`. See [docs/auth-model.md](docs/auth-model.md). ## Still open Tracked here and revisited at the top of the relevant phase. -1. **Phase 2 — Authentik provider creation:** stand up the actual `sherlock` OAuth2 provider in Authentik and document the env vars in `docs/storage.md` (carry-over from the Phase 1 deferral above). -2. **Phase 2 — own-oauth UX:** confirm `sherlock auth ` is the right surface for non-Authentik services like GitHub.com (vs. an interactive `sherlock-mcp.oauth.consent` flow). The no-daemon refactor pushed this off — without a long-lived listener, an explicit command is the natural way to bind the loopback port. +1. **Phase 2 — first MCP + services.d schema validation:** stand up the first `cmd/gitea-mcp/`, create the gitea Authentik provider, prove the lazy `authn.Ensure` flow end-to-end. Locks the `~/.config/sherlock/services.d/*.toml` schema. +2. **Phase 2 — concurrent-auth UX:** when two MCPs both miss the wallet at startup, they race on `127.0.0.1:6990`. Current behaviour: the second one's `Login` returns EADDRINUSE; user re-runs. Phase 2 may add an auth-flow flock so flows queue cleanly, or accept the current behaviour and document it. 3. **Phase 3 — gssh completion semantics:** keep the client-side `__SHERLOCK_DONE__` sentinel, or land a server-side one-shot run-and-close mode in gssh.