Files
sherlock/docs/auth-model.md
T
amacocian e6d29e9710
CI / build (push) Successful in 1m25s
Setup wallet model
2026-05-25 14:15:13 +02:00

3.3 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.

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.