auth

package
v0.4.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: GPL-3.0 Imports: 22 Imported by: 0

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

View Source
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.

View Source
const ShareScopeRead = "read"

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

View Source
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.

View Source
var ErrNoToken = errors.New("auth: no token configured")

ErrNoToken is returned by Load when no token file exists yet.

Functions

func Fingerprint

func Fingerprint(token string) string

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

func Generate() (string, error)

Generate returns a fresh base64url-encoded 256-bit token. It does not persist anything; callers pass the result to Save.

func Load

func Load() (string, error)

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

func Path() (string, error)

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

func Resolve(ctx context.Context, log *slog.Logger) (string, error)

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

func Save(token string) (string, error)

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

func SaveNew(token string) (string, error)

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

func VerifyCLIBearer(presented string) bool

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

func VerifyConsoleBearer(presented string) bool

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

func VerifyConsoleReadBearer(presented string) bool

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

func VerifyMCPBearer(presented string) bool

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 {
	SHA256  string    // hex SHA-256 of the full mgs_ secret
	Scope   string    // always ShareScopeRead
	Expires time.Time // UTC; a share token ALWAYS expires (no zero-means-never here)
}

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL