Documentation
¶
Overview ¶
Package auth manages the shared-secret bearer token that guards the magus MCP HTTP endpoint and provides the HTTP middleware that enforces it. The CLI (`magus config token ...`) and the MCP server resolve and read the exact same token file and share one implementation.
The token is a 256-bit random secret, base64url-encoded, stored 0600 in the user state dir. It is a local shared secret — equivalent in sensitivity to the workspace it grants access to — not an OAuth credential. See the MCP authorization spec: stdio transports derive trust from the process, HTTP transports must authenticate. magus's loopback HTTP server uses this token as defense-in-depth on top of the 127.0.0.1 bind and Host/Origin guard.
Index ¶
- Constants
- Variables
- func Fingerprint(token string) string
- func Generate() (string, error)
- func Load() (string, error)
- func Path() (string, error)
- func Resolve(ctx context.Context, log *slog.Logger) (string, error)
- func Revoke() error
- func Save(token string) (string, error)
- func SaveNew(token string) (string, error)
- func VerifyCLIBearer(presented string) bool
- func VerifyConsoleBearer(presented string) bool
- func VerifyConsoleReadBearer(presented string) bool
- func VerifyMCPBearer(presented string) bool
- type ClientScope
- type ConnectorStore
- func (s *ConnectorStore) Create(name string, expires time.Time, scope ClientScope) (secret string, c ConnectorToken, err error)
- func (s *ConnectorStore) List() []ConnectorToken
- func (s *ConnectorStore) ListScope(want ...ClientScope) []ConnectorToken
- func (s *ConnectorStore) Revoke(nameOrFingerprint string) (ConnectorToken, error)
- func (s *ConnectorStore) VerifyScope(presented string, scope ClientScope) bool
- type ConnectorToken
- type ShareToken
Constants ¶
const DefaultConnectorTTL = 90 * 24 * time.Hour
DefaultConnectorTTL is the default lifetime of a new connector token. It is overridable at creation (including "never"), matching the locked design.
ShareScopeRead is the only scope a share token ever carries: read-only access to the console's read surface. It exists as an explicit, checkable field (not an implicit assumption) so ShareToken.Verify can reject anything else and a future scope cannot silently widen an existing token's reach.
Variables ¶
var ( ErrConnectorExists = errors.New("auth: connector name already exists") ErrConnectorNotFound = errors.New("auth: no matching connector token") )
ErrConnectorExists is returned by Create when a token with the given name is already present; ErrConnectorNotFound by Revoke when nothing matches.
var ErrNoToken = errors.New("auth: no token configured")
ErrNoToken is returned by Load when no token file exists yet.
Functions ¶
func Fingerprint ¶
Fingerprint returns a short, non-reversible identifier for a token (the first 8 hex chars of its SHA-256) suitable for display in status output without revealing the secret.
func Generate ¶
Generate returns a fresh base64url-encoded 256-bit token. It does not persist anything; callers pass the result to Save.
func Load ¶
Load reads and returns the token. It returns ErrNoToken if the file does not exist. As a guard against an accidentally world/group-readable secret, Load refuses a file whose permissions are looser than 0600.
func Path ¶
Path returns the absolute path to the MCP token file: <UserStateDir>/magus/mcp_token. Both the CLI and the daemon resolve it this way so they always agree on the location. The token lives in the state dir, not the config dir, because config may be shared or committed and a secret must not ride along.
func Resolve ¶
Resolve loads the MCP bearer token, generating and persisting one on first use. The MCP server fails closed if Resolve returns an error — the endpoint never serves without a token.
The secret is deliberately never logged: the daemon's logger commonly lands in journald/nohup.out, and a 256-bit shared secret must not persist there. On generation Resolve logs only a notice; the operator retrieves the value out-of-band via `magus config token print`.
func Revoke ¶
func Revoke() error
Revoke deletes the token file. It is not an error if no token exists.
func Save ¶
Save writes token to the token file with 0600 permissions, creating the parent directory if needed. The write is atomic (temp file + rename) so a concurrent reader never observes a half-written secret. It returns the path written.
func SaveNew ¶
SaveNew writes token only if no token file exists yet, using O_EXCL so the create-or-fail decision is atomic. It returns a path on success and an error satisfying errors.Is(err, os.ErrExist) if a token is already present — this closes the check-then-act race between a CLI `generate` and the daemon's auto-provision, so neither can silently clobber a token the other is serving.
func VerifyCLIBearer ¶
VerifyCLIBearer reports whether presented is exactly the retrievable cli token - the OPERATOR tier and nothing else. Connector and share tokens never match here. It exists as its own narrow verifier so privileged mounts (token management) can be guarded at the guard level rather than trusting a handler to re-check the caller's class; both surface verifiers compose it as their bootstrap tier. The token is re-read from disk on every call (rotation takes effect immediately) and a load error fails closed.
func VerifyConsoleBearer ¶ added in v0.4.0
VerifyConsoleBearer reports whether presented may use the console surfaces: /api/ and the console Connect services.
It accepts the operator tier or a non-expired token minted with ScopeConsole. A CONNECTOR token is rejected: it is scoped to /mcp, and the scan skips it.
The operator token stays valid on both surfaces deliberately: it is the bootstrap credential and the CLI's own reads depend on it. That is a named exception, not a residue of the old single-tier design.
func VerifyConsoleReadBearer ¶ added in v0.4.0
VerifyConsoleReadBearer guards the console's READ surface: the operator tier, a full console token, or a viewer token (ScopeConsoleRead).
A viewer token is accepted HERE and nowhere else, which is what makes it a viewer: the mutating console mounts (JobService, MemoryService, the share trigger) use VerifyConsoleBearer, which does not consult ScopeConsoleRead, so a leaked viewer credential can read the console and change nothing. A full console token is accepted too - the write tier is a superset of the read tier, not a sibling.
func VerifyMCPBearer ¶ added in v0.4.0
VerifyMCPBearer reports whether presented may use /mcp: the retrievable cli token OR a non-expired named connector token. Both tiers are re-read from disk on every call, so a rotate, create, or revoke takes effect without restarting the daemon, and each fails closed on a load error.
A CONSOLE token is rejected here by construction - it is not consulted - so a credential handed to a browser cannot reach the agent tool surface.
Types ¶
type ClientScope ¶ added in v0.4.0
type ClientScope string
ClientScope is the SURFACE a stored client token may reach. It is the field that makes MCP and console credentials genuinely different things rather than one secret with two names: the verifiers filter on it, so a token minted for one surface is rejected by the other even though both live in this store.
const ( // ScopeMCP reaches /mcp and nothing else. The tier external agents hold. ScopeMCP ClientScope = "mcp" // ScopeConsole reaches the console read/control surfaces and never /mcp, so a // credential handed to a browser cannot drive the agent tool surface. This is the // tier that can actually change things: submit jobs, edit memory, open a share. ScopeConsole ClientScope = "console" // ScopeConsoleRead is the VIEWER tier: the console's read surface and nothing // else. It is the same route set the LAN share listener exposes (daemon.go's // shareGuarded), defined once and reused, so "what a viewer may see" has exactly // one answer whether the viewer is a phone or a second browser on loopback. ScopeConsoleRead ClientScope = "console-read" )
type ConnectorStore ¶
type ConnectorStore struct {
// contains filtered or unexported fields
}
ConnectorStore is the on-disk set of connector tokens: one file per token in <UserStateDir>/magus/connectors.d/. Load it, mutate via Create/Revoke, or read via List/Verify. The in-memory snapshot is guarded by mu so List/Verify on one shared store are safe against a concurrent Create/Revoke in the same process.
A DIRECTORY rather than the single connectors.json it replaces, and the payoff is that the cross-process lock is gone rather than any change in what is stored. One array in one file made every mutation a read-modify-write, so Create and Revoke needed a lock file, a retry loop, and a stale-lock steal heuristic to avoid losing an entry. One file per token makes Create a dropin.Publish - whose atomic link is also the uniqueness check, for free - and Revoke an unlink. Neither reads the other tokens, so there is nothing left to serialize.
It is also the ergonomics: revoking is `rm connectors.d/<name>.json`, which works when magus does not.
func LoadConnectorStore ¶
func LoadConnectorStore() (*ConnectorStore, error)
LoadConnectorStore reads the connector store, migrating a legacy connectors.json first. A missing directory is not an error: it returns an empty store ready to Create into.
func (*ConnectorStore) Create ¶
func (s *ConnectorStore) Create(name string, expires time.Time, scope ClientScope) (secret string, c ConnectorToken, err error)
Create mints a new connector token named name that expires at expires (a zero time means it never expires), stores its SHA-256, and returns the plaintext secret ONCE - it cannot be recovered later. name must be non-empty and unique (ErrConnectorExists otherwise). Uniqueness comes from dropin.Publish's O_EXCL create against the token file itself, not a lock, so a concurrent Create of the same name cannot duplicate it; only the final append to the in-memory snapshot runs under s.mu.
func (*ConnectorStore) List ¶
func (s *ConnectorStore) List() []ConnectorToken
List returns the stored connector records (hashes and fingerprints, never the secrets), sorted by name. The slice is a copy, so a caller cannot mutate the store's in-memory state.
func (*ConnectorStore) ListScope ¶ added in v0.4.0
func (s *ConnectorStore) ListScope(want ...ClientScope) []ConnectorToken
ListScope returns only the tokens whose scope is in want. One store holds both the MCP and console tiers, so every surface that shows or revokes tokens must filter: listing an agent credential under a console command (or the reverse) would present the two as one pool, which is the confusion the scopes exist to prevent.
func (*ConnectorStore) Revoke ¶
func (s *ConnectorStore) Revoke(nameOrFingerprint string) (ConnectorToken, error)
Revoke deletes the record matching nameOrFingerprint, resolved as: an exact name, then an exact fingerprint, then a unique fingerprint prefix. It returns the removed record, ErrConnectorNotFound if nothing matches, or an error if a short prefix is ambiguous. The re-read, resolve, and os.Remove all run outside s.mu against the freshly re-read store; only the final splice of the in-memory snapshot is locked.
func (*ConnectorStore) VerifyScope ¶ added in v0.4.0
func (s *ConnectorStore) VerifyScope(presented string, scope ClientScope) bool
VerifyScope reports whether presented is a valid, non-expired connector token minted for scope. It rejects a malformed or checksum-failing token OFFLINE before any hash work, then compares SHA-256 digests with subtle.ConstantTimeCompare against every non-expired stored record carrying that scope. Expired records never match, and neither does a token minted for a different surface - that filter is what keeps the tiers disjoint rather than merely labeled.
type ConnectorToken ¶
type ConnectorToken struct {
Name string `json:"name"`
SHA256 string `json:"sha256"` // hex SHA-256 of the full mgs_ token
Fingerprint string `json:"fingerprint"` // first 8 hex of SHA256, for display
Created time.Time `json:"created"` // UTC
Expires time.Time `json:"expires"` // UTC; zero means never expires
// compat(until: no store still holds a record written without a scope): records
// predate this field, and an absent scope decodes as "". Scope() reads that as
// ScopeMCP, which is what every such record was minted for - the console tier did
// not exist when they were written. Observe it is safe to drop by checking that
// every file under connectors.d carries a "scope" key.
Scope ClientScope `json:"scope,omitempty"`
}
ConnectorToken is one named connector token record. It holds only the hash and a display fingerprint - never the secret.
func (ConnectorToken) EffectiveScope ¶ added in v0.4.0
func (c ConnectorToken) EffectiveScope() ClientScope
EffectiveScope reports the token's surface, defaulting a scopeless legacy record to ScopeMCP. Read through this rather than the field so the default lives in exactly one place.
type ShareToken ¶
type ShareToken struct {
}
ShareToken is one minted share credential, held in daemon memory only. It stores the hash of the secret (never the secret itself), its scope, and its expiry. The zero value verifies nothing.
func MintShareToken ¶
func MintShareToken(ttl time.Duration) (secret string, tok ShareToken, err error)
MintShareToken returns a fresh read-only share token that expires ttl from now, alongside the plaintext secret shown ONCE (embedded in the QR URL). The secret is in the mgs_ connector wire format so secret scanners catch a leak, but its scope keeps it off every mutating surface. ttl must be positive.
func (ShareToken) Expired ¶
func (t ShareToken) Expired(now time.Time) bool
Expired reports whether the token is past its expiry as of now.
func (ShareToken) Verify ¶
func (t ShareToken) Verify(presented string, now time.Time) bool
Verify reports whether presented is exactly this share token, unexpired, and read-scoped. It rejects a malformed or checksum-failing token OFFLINE before any hash work, then compares SHA-256 digests with subtle.ConstantTimeCompare so the check reveals neither the secret's bytes nor its length. The scope gate is what makes "read-only" a property the verifier enforces rather than a label: a token whose scope is not ShareScopeRead never matches, even if its bytes do. A zero-value ShareToken (empty SHA256) verifies nothing.