109 lines
3.7 KiB
Go
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
|
|
}
|