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:
- Wallet has fresh tokens for
gitea→ return them. - Wallet has stale tokens with a usable refresh token → refresh under a cross-process flock, persist, return.
- 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 viaxdg-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 initialEnsureand pushes the first token to the callback.Run(ctx)sleeps untilRefreshSkewbefore the token expires, refreshes under the same cross-process flock asEnsureFresh, 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 nextsherlockinvocation.- The callback is required (a nil callback panics at construction). The request
path never polls: it reads the current bearer from the
TokenHolderthe 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.