Files
sherlock/internal/authn/ensure.go
T
amacocian 1c2108dd2b
Release / tag (push) Successful in 2s
CI / build (push) Successful in 3m5s
Fix fmt
2026-06-13 13:40:24 +02:00

109 lines
3.7 KiB
Go

//go:build unix
package authn
import (
"context"
"errors"
"gitea.alexandru.macocian.me/amacocian/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. The sibling login lock (same directory) serialises
// fresh OAuth flows so concurrent first-use logins don't collide on
// the fixed loopback port. 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.
}
// Fresh login. Serialise across processes: Login binds the fixed
// loopback port (127.0.0.1:6990) for the OAuth callback, so two MCPs
// hitting their first tool call at once would otherwise collide with
// EADDRINUSE. The login lock lets them queue; the second flow reuses
// the operator's existing Authentik browser session, so it's instant.
var out keyring.TokenSet
lockErr := withLoginLock(ctx, loginLockPathFor(opts.LockPath), func() error {
// Re-check under the lock: a concurrent flow may have already
// authenticated this same service while we waited.
if fresh, e := EnsureFresh(ctx, store, service, EnsureFreshOptions{
LockPath: opts.LockPath,
Discoverer: opts.Discoverer,
}); e == nil {
out = fresh
return nil
}
res, e := Login(ctx, cfg, LoginOptions{
OnAuthURL: opts.OnAuthURL,
Discoverer: opts.Discoverer,
})
if e != nil {
return e
}
out = res.Tokens
return store.Set(service, res.Tokens)
})
if lockErr != nil {
return out, lockErr
}
return out, nil
}