Files
sherlock/docs/auth-model.md
T
amacocian 40a779d4a8
CI / build (push) Failing after 1s
Fix login lock
2026-06-13 13:06:07 +02:00

5.6 KiB

Auth model

How sherlock obtains and distributes credentials.

Mental model: wallet of OAuth sessions

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.

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.

Authentication is lazy and per-MCP

sherlock login does not exist. The operator does not preemptively authenticate. Each MCP at startup calls

ts, err := authn.Ensure(ctx, store, "gitea", cfg, opts)

Ensure (in internal/authn) handles every case:

  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.

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.

Concurrent first-use is serialised

Because the loopback port (127.0.0.1:6990) is a single machine-wide resource, two MCPs hitting their first tool call at the same moment would otherwise both try to bind it and one would fail with EADDRINUSE. Ensure wraps the fresh login in an exclusive flock on $XDG_RUNTIME_DIR/sherlock.login.lock (sibling of the refresh lock, but separate so a long, human-in-the-loop login never blocks a quick refresh). The flows queue; after the first one authenticates, the rest reuse the operator's existing Authentik browser session and complete without a second click. Lock acquisition is context-cancellable, so a waiting flow gives up cleanly if its request is cancelled. See internal/authn/loginlock.go.

Token renewal

Authentik access tokens are short-lived (the sherlock-cli provider issues ~5-minute tokens). An MCP that captured a token once at startup would start getting 401s a few minutes into a long agent session. So MCPs do not hold a static token — they hold a renewer.

authn.TokenSource owns the refresh loop; the MCP supplies a mandatory callback that the source invokes with every new token:

holder := authn.NewTokenHolder()
src := authn.NewTokenSource(store, "grafana", cfg, opts, holder.Set)

ts, err := src.Start(ctx)   // initial OAuth (browser on first use); calls holder.Set
go src.Run(ctx)             // renews ahead of expiry; calls holder.Set on every rotation
  • Start(ctx) does the initial Ensure and pushes the first token to the callback.
  • Run(ctx) sleeps until RefreshSkew before the token expires, refreshes under the same cross-process flock as EnsureFresh, and — when the access token actually rotates — invokes the callback again. It is refresh-only and never opens a browser; if the refresh token itself has expired it backs off and leaves re-login to the next sherlock invocation.
  • The callback is required (a nil callback panics at construction). The request path never polls: it reads the current bearer from the TokenHolder the callback writes (holder.AccessToken / holder.Bearer), which is lock-free and always reflects the latest renewal.

This is why every MCP's API client takes a token getter, not a token string: grafana-mcp reads it from a per-request BaseTransport, gitea-mcp and gssh-mcp from holder.AccessToken on each REST call and WebSocket dial.

What lives in services.d/<name>.toml (Phase 2)

Each MCP needs an OIDC Config (issuer + client ID + scopes) to pass to Ensure. The operator-editable source of truth is

[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

[mcp]
env_var = "GITEA_TOKEN"   # what sherlock injects into the agent env (Phase 2+)

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 for the full schema.

Audience binding

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 an explicit override in the service's TOML (Phase 2).

Out of scope for sherlock

  • Issuing SSH certs (gssh handles this — gssh-integration.md).
  • Multi-user / shared tenancy. Sherlock is per-operator, full stop.
  • 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.