Documentation
¶
Overview ¶
Package auth is the Go binding of the developer profile: the directory `stash auth login` writes (~/.cipherstash, or CS_CONFIG_PATH), read through the stack-profile crate running unmodified inside a WASI guest under wazero (CGO_ENABLED=0), so the on-disk layout is never re-derived by hand in Go.
Shape ¶
A ProfileStore is one guest instance over one mounted directory. Resolve finds the profile directory the way the Rust crate does (CS_CONFIG_PATH, then ~/.cipherstash); Open takes one; OpenWithoutProfile mounts nothing, for the access-key and OIDC strategies where there is no profile. The guest is given that directory and nothing else: no environment, no other path, and no way out through a symlink inside it, which the mount refuses to follow. Authentication HTTP requests go through the Go host's transport import. Everything the napi binding of stack-profile exposes is a method here, named as in Rust: the current workspace (ProfileStore.CurrentWorkspace, ProfileStore.SetCurrentWorkspace, ProfileStore.ClearCurrentWorkspace), the workspaces on disk (ProfileStore.ListWorkspaces), a store scoped to one workspace (ProfileStore.WorkspaceStore, ProfileStore.CurrentWorkspaceStore), and the typed reads of the files a workspace holds: ProfileStore.SecretKey hands out the ZeroKMS client key as the opaque ClientKey that encrypt.NewCredentials takes, ProfileStore.Token the stored access token, ProfileStore.DeviceIdentity the identity the CLI created. ProfileStore.Close releases the guest; stores scoped from it are closed with it.
For authentication and refresh, use ProfileStore.AccessKey, ProfileStore.OIDC, ProfileStore.DeviceSession, or ProfileStore.Auto. Each returns a Strategy, which is what encrypt.NewCredentials takes for the bearer token: the only way a token reaches an encrypt client. A raw token — ProfileStore.Token's included — cannot be refreshed when it expires, and would bypass the cross-process lock a device-session refresh holds with the CLI, so encrypt does not accept one. A strategy lives in its store's guest, and closing the store closes it. A encrypt client given one never closes the strategy or the store: both are the caller's, and stay open until the client is closed. OAuth2TokenSource adapts an existing golang.org/x/oauth2.TokenSource into the OIDC provider interface.
A call whose context ends, Strategy.Token included, fails with the context's error and leaves the store and its strategies open: a request cancelled while its token is exchanged or refreshed does not stop the encrypt clients that read tokens from the same store. A request to the token service is cancelled with its caller, except a device session's refresh: the service rotates the refresh token when it answers one, so the caller returns at once while the refresh runs on, for up to 10 seconds, and the store keeps what it sends back for the next call. Strategy.Close waits for such a refresh. The store closes itself, and every later call is ErrState, only when its guest traps or a call runs on for 30 seconds after its context ended.
Why a second guest ¶
The crypto guest behind encrypt has no filesystem and no environment: a bug or compromise inside it cannot read credentials off disk. Mounting the profile into it would trade that away, and it is the module that handles plaintext and data keys. So the profile lives in its own module with its own, smaller blast radius: one directory of credentials. ADR-0005 in packages/stack-encrypt/docs/adr records the decision and the alternatives.
What the guest cannot do ¶
WASI preview 1 has no file locking, so the guest takes none. Go holds the same cross-process lock as the Rust CLI, on the path ProfileStore.LockPath names, across each device-session call. The guest re-reads auth.json after the lock and saves a rotated token before Go releases it. Creating a device identity is native-only in the crate and CLI territory; this package only reads one. Files the guest creates are mode 0600, which is wazero's create mode rather than the crate's own (skipped on wasm32), so a test pins it.
Memory ¶
The guest's memory holds the client key and the token while a read is in flight. It is supplied the way encrypt's is — reserved once so it never moves, locked in RAM and excluded from core dumps where the platform allows, wiped before release, none of it depending on Close running — and ProfileStore.MemoryLocked reports whether the lock was granted. RequireLockedMemory makes a refused lock an error from Open.
Index ¶
- Variables
- type Cause
- type ClientKey
- type DeviceIdentity
- type Diagnostic
- type OIDCProvider
- type OIDCProviderFunc
- type Option
- type ProfileStore
- func (s *ProfileStore) AccessKey(ctx context.Context, crn, key string, opts ...StrategyOption) (*Strategy, error)
- func (s *ProfileStore) Auto(ctx context.Context, opts ...StrategyOption) (*Strategy, error)
- func (s *ProfileStore) ClearCurrentWorkspace(ctx context.Context) error
- func (s *ProfileStore) Close() error
- func (s *ProfileStore) CurrentWorkspace(ctx context.Context) (string, error)
- func (s *ProfileStore) CurrentWorkspaceStore(ctx context.Context) (*ProfileStore, error)
- func (s *ProfileStore) DeviceIdentity(ctx context.Context) (DeviceIdentity, error)
- func (s *ProfileStore) DeviceSession(ctx context.Context, opts ...StrategyOption) (*Strategy, error)
- func (s *ProfileStore) Dir() string
- func (s *ProfileStore) ListWorkspaces(ctx context.Context) ([]string, error)
- func (s *ProfileStore) LockPath(ctx context.Context, filename string) (string, error)
- func (s *ProfileStore) LogValue() slog.Value
- func (s *ProfileStore) MemoryLockError() error
- func (s *ProfileStore) MemoryLocked() bool
- func (s *ProfileStore) OIDC(ctx context.Context, crn string, provider OIDCProvider, opts ...StrategyOption) (*Strategy, error)
- func (s *ProfileStore) SecretKey(ctx context.Context) (clientID string, key *ClientKey, err error)
- func (s *ProfileStore) SetCurrentWorkspace(ctx context.Context, id string) error
- func (s *ProfileStore) String() string
- func (s *ProfileStore) Token(ctx context.Context) (Token, error)
- func (s *ProfileStore) WorkspaceStore(ctx context.Context, id string) (*ProfileStore, error)
- type Strategy
- type StrategyOption
- type Token
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNotFound is a profile file that does not exist in the store asked: // no secretkey.json, auth.json or device.json there. For the workspace's // files that means nothing has logged in to it on this machine. ErrNotFound = guest.ErrProfileNotFound // ErrInvalid is a profile file that is not the JSON its type expects. ErrInvalid = guest.ErrProfileJSON // ErrIO is a profile file that could not be read or written. ErrIO = guest.ErrProfileIO // ErrInvalidFilename is a filename the store refuses: empty, absolute, // or naming a path. ErrInvalidFilename = guest.ErrInvalidFilename // ErrNoCurrentWorkspace is a workspace-scoped operation with no current // workspace set: run `stash auth login`. ErrNoCurrentWorkspace = guest.ErrNoCurrentWorkspace // ErrInvalidWorkspaceID is a workspace id that is not sixteen base32 // characters. Refused before any path is built from it. ErrInvalidWorkspaceID = guest.ErrInvalidWorkspaceID // ErrWorkspaceNotFound is a workspace with no directory under // workspaces/: nothing has logged in to it on this machine. ErrWorkspaceNotFound = guest.ErrWorkspaceNotFound // ErrEncoding is an input the guest refused: a directory, id or filename // that is not UTF-8. ErrEncoding = guest.ErrEncoding // ErrState is a call on a store that has been closed. ErrState = guest.ErrState // ErrInternal is a guest panic or any other unexpected guest failure. ErrInternal = guest.ErrInternal // ErrInvalidGrant is an OAuth refresh grant the auth server rejected. ErrInvalidGrant = guest.ErrAuthInvalidGrant // ErrInvalidClient is a client credential the auth server rejected. ErrInvalidClient = guest.ErrAuthInvalidClient // ErrUsageLimit is an account blocked by its usage allowance. ErrUsageLimit = guest.ErrAuthUsageLimit // ErrNotAuthenticated means no usable auth credential is available. ErrNotAuthenticated = guest.ErrAuthNotAuthenticated // ErrTransport is a failed auth HTTP exchange or response read. ErrTransport = guest.ErrAuthTransport // ErrConfig is invalid auth configuration or token data. ErrConfig = guest.ErrAuthConfig // ErrOther is an auth failure outside the actionable categories above. ErrOther = guest.ErrAuthOther // ErrMemoryLock is guest memory that could not be locked in RAM (or, on // Linux, excluded from core dumps). Open returns it under // [RequireLockedMemory]; otherwise [ProfileStore.MemoryLockError] // reports it and the store works on with unlocked memory. ErrMemoryLock = guest.ErrMemoryLock // ErrNoProfile is a profile directory that does not exist: nothing has // logged in on this machine, or CS_CONFIG_PATH names the wrong place. ErrNoProfile = errors.New("auth: no profile directory; run `stash auth login`") )
Failure kinds the profile reports. The guest reports a status code from the one table every guest shares, so these are the sentinels of the shared decoder exposed under this package's names; an error from encrypt of the same kind is the same value. Check one with errors.Is; the detail behind it is a Diagnostic.
var ErrGuestNotBuilt = errors.New("auth: guest module not built — run `mise run wasm:auth-guest:build`")
ErrGuestNotBuilt is returned by Open when no guest module is embedded and none was supplied with WithGuest.
Functions ¶
This section is empty.
Types ¶
type Cause ¶
Cause is one error in a Diagnostic's cause chain: a Code and a Message. A cause from a library outside the stack crates has no Code, and its Message is a description the guest vouches for, never that library's own text.
type ClientKey ¶
ClientKey is the ZeroKMS client key as ProfileStore.SecretKey reads it out of secretkey.json: opaque (it prints a redaction under every verb and hands its bytes to no caller) and wiped once consumed. It is the same type as encrypt.ClientKey, by identity, so a key read here goes straight into encrypt.NewCredentials. This package does not import encrypt: a binary that only wants the profile does not carry the crypto guest. (encrypt imports this one, for AutoCredentials.)
type DeviceIdentity ¶
type DeviceIdentity struct {
// DeviceInstanceID uniquely identifies this CLI installation.
DeviceInstanceID string
// DeviceName is a human-readable name, the hostname by default.
DeviceName string
}
DeviceIdentity is the identity the CLI created for this machine, read from device.json in this store (the profile root). Read-only: creating one is the CLI's.
type Diagnostic ¶
type Diagnostic = guest.Diagnostic
Diagnostic is the full error behind a failure the guest reports, beside the kind: every such failure is a *Diagnostic wrapping one of the sentinels above, so errors.Is matches the kind and errors.As reads the rest:
var d *auth.Diagnostic
if errors.As(err, &d) {
log.Printf("%s: %s (%s)", d.Code, d.Message, d.Help)
}
Its fields are Code ("stack_profile::not_found", "stack_auth::invalid_crn", ...; stable), Message (what Error returns after the sentinel's text), Help, URL, Severity, Fields (the structured fields, by name: a profile file's "path", a JSON error's "line" and "column") and Causes (the errors behind it, outermost first).
What one may carry is fixed: workspace ids, CRNs and regions, profile file paths, HTTP statuses and the auth server's error descriptions. Never a token, an access key, a client key or a response body.
Errors the store raises itself carry none: a closed store (ErrState), a guest that did not return, ErrMemoryLock, ErrNoProfile. A guest built before the detail existed returns the bare sentinel too.
It is the same type as encrypt.Diagnostic, by identity.
type OIDCProvider ¶
OIDCProvider supplies the identity-provider JWT of the user a call is for. The Rust federation strategy asks on every Strategy.Token call: the JWT is how it tells one user from another, and it keeps one CTS token per distinct JWT, exchanging a JWT only when it has no unexpired CTS token. So a server that serves many users through one client must return the JWT of the user behind ctx, not a value captured once, and should keep the call cheap, as identity-provider SDKs that cache their session are.
Token runs inside the guest call that needs it, while the ProfileStore that owns the strategy is locked. It must not call back into that ProfileStore or any strategy of it (reading a stored token, building another strategy): the call would wait on the same lock and deadlock rather than fail. OAuth2TokenSource does not touch the store, so it is safe to use from here.
func OAuth2TokenSource ¶
func OAuth2TokenSource(source oauth2.TokenSource) OIDCProvider
OAuth2TokenSource adapts an existing x/oauth2 provider to OIDCProvider. Its AccessToken is read afresh whenever the Rust federation strategy asks.
type OIDCProviderFunc ¶
OIDCProviderFunc adapts a function to OIDCProvider.
type Option ¶
type Option func(*options)
Option configures Resolve and Open.
func RequireLockedMemory ¶
func RequireLockedMemory() Option
RequireLockedMemory makes Open fail with ErrMemoryLock when the guest's memory cannot be locked in RAM or, on Linux, excluded from core dumps, instead of continuing with memory that may be swapped or dumped and reporting so through ProfileStore.MemoryLocked. It holds for the life of the store, as encrypt's WithRequireLockedMemory does for a client.
func WithRoundTripper ¶
func WithRoundTripper(rt http.RoundTripper) Option
WithRoundTripper sends the guest's authentication requests through rt. The default is http.DefaultTransport.
type ProfileStore ¶
type ProfileStore struct {
// contains filtered or unexported fields
}
ProfileStore is a profile directory: the root Resolve or Open returns, or a workspace directory under it from ProfileStore.WorkspaceStore. All stores from one root share one guest instance over the one mounted directory; Close on any of them releases it. It is safe for concurrent use; calls are serialised, because a wasm instance is single-threaded.
A call whose context ends fails with the context's error, and only that call: a request cancelled while its token is fetched or refreshed never closes the profile, or the strategies and encrypt clients that read tokens from it. A device session's refresh already sent finishes in the background, for up to 10 seconds, so a rotated refresh token is never lost (see refreshGrace). The profile closes itself, and every later call is ErrState, only when the guest's state is unknown: it trapped, or it went on running long after its caller gave up, which is a bug.
func Open ¶
Open instantiates the guest over dir, an existing profile directory, mounted as the one directory the guest can see. Nothing is read until a method asks; nothing is written unless a method writes.
func OpenWithoutProfile ¶
func OpenWithoutProfile(ctx context.Context, opts ...Option) (*ProfileStore, error)
OpenWithoutProfile instantiates the guest with no directory mounted: no filesystem at all. It is for the strategies that need no profile — ProfileStore.AccessKey and ProfileStore.OIDC — where there is none to open: CI, a container, a server authenticating by federation. Every profile read on it fails with ErrNoProfile, and ProfileStore.Auto on it is the environment's access key or ErrNotAuthenticated, as stack-auth's AutoStrategy is with no profile store.
func Resolve ¶
func Resolve(ctx context.Context, opts ...Option) (*ProfileStore, error)
Resolve opens the profile directory the Rust crate would: CS_CONFIG_PATH when set and not blank, else ~/.cipherstash. The directory must exist; `stash auth login` creates it.
func (*ProfileStore) AccessKey ¶
func (s *ProfileStore) AccessKey(ctx context.Context, crn, key string, opts ...StrategyOption) (*Strategy, error)
AccessKey constructs stack-auth's access-key strategy for crn. The key stays in the guest after construction; it is not sent on each Token call.
func (*ProfileStore) Auto ¶
func (s *ProfileStore) Auto(ctx context.Context, opts ...StrategyOption) (*Strategy, error)
Auto follows stack-auth's detection order against the Go host's environment: access key first, then the current workspace's device token.
func (*ProfileStore) ClearCurrentWorkspace ¶
func (s *ProfileStore) ClearCurrentWorkspace(ctx context.Context) error
ClearCurrentWorkspace removes the current workspace selection. Nothing to remove is not an error.
func (*ProfileStore) Close ¶
func (s *ProfileStore) Close() error
Close releases the guest: its shutdown wipes every buffer it still holds, and the runtime close wipes and frees its memory. Idempotent. Every store of this profile is closed by it; a call on any of them after it fails with ErrState.
func (*ProfileStore) CurrentWorkspace ¶
func (s *ProfileStore) CurrentWorkspace(ctx context.Context) (string, error)
CurrentWorkspace is the current workspace id, or ErrNoCurrentWorkspace.
func (*ProfileStore) CurrentWorkspaceStore ¶
func (s *ProfileStore) CurrentWorkspaceStore(ctx context.Context) (*ProfileStore, error)
CurrentWorkspaceStore is ProfileStore.WorkspaceStore for the current workspace, or ErrNoCurrentWorkspace.
func (*ProfileStore) DeviceIdentity ¶
func (s *ProfileStore) DeviceIdentity(ctx context.Context) (DeviceIdentity, error)
DeviceIdentity reads device.json in this store.
func (*ProfileStore) DeviceSession ¶
func (s *ProfileStore) DeviceSession(ctx context.Context, opts ...StrategyOption) (*Strategy, error)
DeviceSession uses this workspace store's auth.json. Fresh tokens are read without a lock. A refresh takes the same cross-process lock as the Rust CLI, then the guest re-reads and saves before the lock is released.
func (*ProfileStore) Dir ¶
func (s *ProfileStore) Dir() string
Dir is the store's directory on the host: the profile root, or the workspace directory under it. Empty for a store from OpenWithoutProfile.
func (*ProfileStore) ListWorkspaces ¶
func (s *ProfileStore) ListWorkspaces(ctx context.Context) ([]string, error)
ListWorkspaces is the workspace ids with profile data on disk, sorted.
func (*ProfileStore) LockPath ¶
LockPath is the host path of the lock file the Rust crate takes for filename in this store: a sibling `.<filename>.lock`. Nothing is created or locked. DeviceSession holds this lock across the guest's refresh call; the guest cannot lock under WASI. This package never composes a profile path itself.
func (*ProfileStore) LogValue ¶
func (s *ProfileStore) LogValue() slog.Value
LogValue implements slog.LogValuer: the directory, and the memory state.
func (*ProfileStore) MemoryLockError ¶
func (s *ProfileStore) MemoryLockError() error
MemoryLockError is why MemoryLocked is false: an error wrapping ErrMemoryLock that names what was refused and the limit that refused it. Nil while the memory is locked.
func (*ProfileStore) MemoryLocked ¶
func (s *ProfileStore) MemoryLocked() bool
MemoryLocked reports whether the guest's memory — where the client key and the token pass through — is locked in RAM and, on Linux, excluded from core dumps. See encrypt's Client.MemoryLocked for what false means and what to do about it.
func (*ProfileStore) OIDC ¶
func (s *ProfileStore) OIDC(ctx context.Context, crn string, provider OIDCProvider, opts ...StrategyOption) (*Strategy, error)
OIDC constructs stack-auth's federation strategy. provider is called on every Token call for the IdP JWT of the user that call is for; the strategy keeps one CTS token per distinct JWT and mints a new one only for a JWT that has no unexpired token. WithCacheCapacity sets how many JWTs that cache holds.
func (*ProfileStore) SecretKey ¶
SecretKey reads secretkey.json in this store (a workspace store; the root holds none): the ZeroKMS client id and the client key, the latter as the opaque ClientKey encrypt.NewCredentials takes. The transport copy of the key is wiped once it is in the ClientKey; the key is then the caller's to consume.
func (*ProfileStore) SetCurrentWorkspace ¶
func (s *ProfileStore) SetCurrentWorkspace(ctx context.Context, id string) error
SetCurrentWorkspace makes id the current workspace. The workspace must already have profile data on this machine (ErrWorkspaceNotFound otherwise): a login creates it, this package never does.
func (*ProfileStore) String ¶
func (s *ProfileStore) String() string
String prints the store's directory and memory state. Nothing secret.
func (*ProfileStore) Token ¶
func (s *ProfileStore) Token(ctx context.Context) (Token, error)
Token reads auth.json in this store (a workspace store; the root holds none). The transport copy of the token is wiped once it is read out.
func (*ProfileStore) WorkspaceStore ¶
func (s *ProfileStore) WorkspaceStore(ctx context.Context, id string) (*ProfileStore, error)
WorkspaceStore is the store scoped to workspace id: the directory workspaces/<id> under this store, as the guest names it after validating the id (ErrInvalidWorkspaceID otherwise). The directory need not exist yet; reads from it report ErrNotFound. It shares this store's guest and is closed with it.
type Strategy ¶
type Strategy struct {
// contains filtered or unexported fields
}
Strategy is a Rust stack-auth strategy retained inside the credential guest: the source of the bearer token encrypt.NewCredentials takes. Close drops its cached credential; closing the parent profile closes all its strategies. It is the caller's to close: an encrypt client that was given it asks it for tokens but never closes it, so it must stay open until the client is closed.
func (*Strategy) Close ¶
Close drops the strategy's cached token and unregisters its provider. It waits for a call in flight, including a device session's refresh that a cancelled Token left running: that refresh finishes, for up to 10 seconds, so the rotated refresh token it brings is saved rather than lost.
func (*Strategy) MemoryLockError ¶
MemoryLockError is why the memory the strategy lives in is not locked in RAM: its ProfileStore's guest, where its token, its access key or refresh token, and any client key read through the same store are held. It is ProfileStore.MemoryLockError of that store, asked now rather than when the strategy was made: under best-effort locking the guest can commit unlocked memory later, on a growth for a token exchange or a refresh. Nil while the memory is locked. It can be asked after Close.
type StrategyOption ¶
type StrategyOption func(*strategyOptions)
StrategyOption configures an auth strategy.
func WithBaseURL ¶
func WithBaseURL(url string) StrategyOption
WithBaseURL overrides CTS service discovery for one strategy.
func WithCacheCapacity ¶
func WithCacheCapacity(n uint32) StrategyOption
WithCacheCapacity sets how many distinct IdP JWTs an OIDC strategy keeps a CTS token for (1024 unless set). When the cache is full the least recently used JWT's token is dropped and that user is exchanged again on their next call, so size it to the users one strategy serves within a CTS token's lifetime (about 15 minutes); each entry holds that user's JWT and CTS token. Zero caches nothing. Only ProfileStore.OIDC reads it.
type Token ¶
type Token struct {
// AccessToken is the bearer credential.
AccessToken string
// TokenType is the token's type, "Bearer".
TokenType string
// ExpiresAt is when the token stops being usable, from the stored
// epoch timestamp.
ExpiresAt time.Time
// Region is the region the token was issued for, if stored.
Region string
// ClientID is the ZeroKMS client id the login provisioned, if stored.
ClientID string
// DeviceInstanceID is the identity of the device that logged in, if
// stored.
DeviceInstanceID string
}
Token is the stored access token, as auth.json holds it and ProfileStore.Token reads it through the Rust crate's own type. The refresh token is not in it: the guest handles refresh through a Strategy.