Documentation
¶
Overview ¶
Package localauth owns the local operator credential.
A gateway API key belongs to an account and travels: it is issued, stored in a browser, pasted into an SDK, and revoked. The local admin token belongs to nobody and does not travel. It is a file on the machine running the gateway, readable only by the account that runs it, and holding it is a claim about where you are rather than who you are.
The two are kept apart deliberately. An operator who has just installed Starport has no key and no way to issue one, because issuing a key is itself an admin act. The local admin token is what breaks that circle: the machine vouches for the person sitting at it, and everything else follows from there.
Index ¶
- Constants
- Variables
- func AllowsExposure(bindHost string, token Token) bool
- func BrowsableBase(host string, port int, secure bool) string
- func ClearedSessionCookies(secure bool) []*http.Cookie
- func LaunchURL(base string, ticket string) (string, error)
- func MintTicket(token Token, now time.Time) (string, error)
- func SessionCookies(value string, session Session, secure bool) []*http.Cookie
- func TicketPrefix(ticket string) string
- type AccountResolver
- type Gate
- func (g *Gate) Generation() uint64
- func (g *Gate) Grant(kind GrantKind) (Grant, error)
- func (g *Gate) MintSession(kind GrantKind, request GrantRequest, now time.Time) (string, Session, error)
- func (g *Gate) MintTicket(now time.Time) (string, error)
- func (g *Gate) Redeem(ticket string, now time.Time) (string, Session, error)
- func (g *Gate) SessionAccounts(ctx context.Context, session Session) ([]string, bool, error)
- func (g *Gate) UseAccountResolver(resolver AccountResolver)
- func (g *Gate) UseIdentityProvider(provider IdentityProvider)
- func (g *Gate) Verify(cookie string, now time.Time) (Session, error)
- type Grant
- type GrantKind
- type GrantRequest
- type IdentityProvider
- type Session
- type Store
- type Tickets
- type Token
Constants ¶
const ( // SessionCookie carries the console session. It is HttpOnly, so the console // never reads it: the gateway is the only party that needs the value, and a // credential a script can read is a credential a script can send somewhere. SessionCookie = "starport_session" // SessionMarkerCookie tells the console that a session exists. It carries // no secret and authenticates nothing — the gateway ignores it entirely. // It exists so the console can render the shell without a request // whose only purpose is to ask whether the next request would work. SessionMarkerCookie = "starport_session_present" // SessionTTL is how long one launch lasts. It is a working day rather than // a month: the way back is `starport ui`, which costs a command and no // credential handling, so a short life costs an operator almost nothing and // bounds how long a borrowed laptop keeps a live session. SessionTTL = 12 * time.Hour )
const ( // TicketTTL bounds how long a launch ticket is worth anything. It covers // the time between a command printing a URL and a browser opening it, which // is a person clicking or a browser launching, and nothing else. A ticket // travels in a URL, and a URL is history, a shell log, and a paste, so the // value in it should be worthless by the time anyone reads it back. TicketTTL = 90 * time.Second // TicketParam is the query parameter /launch reads. TicketParam = "lt" // TicketLogPrefixLength is how much of a ticket may be logged. It is enough // to correlate one redemption with the command that minted it and far too // little to replay. TicketLogPrefixLength = 8 )
const ( // TokenVersion is the record layout this binary reads and writes. A record // from a newer binary is refused rather than guessed at, because a field // this version does not know about could be the one carrying a restriction. TokenVersion = 1 // TokenScope names what the token authorizes. It is stored in the record so // a future token with a narrower scope cannot be read as this one. TokenScope = "local-admin" // TokenPrefix marks the secret as a local admin token at a glance. Gateway // API keys begin "STARPORT_", so a lowercase prefix means neither can be // mistaken for the other in a terminal, a log line, or a support thread. TokenPrefix = "starport_local_" // RotateCommand names the command that clears the exposure refusal. The // startup message and the CLI status line both point at it, and a refusal // that named a command spelled differently from the one that fixes it would // send an operator looking for a command that does not exist. RotateCommand = "starport auth rotate" )
const GatewayAPIKeyPrefix = "STARPORT_"
GatewayAPIKeyPrefix is how a gateway API key announces itself.
This package does not issue those keys and never accepts one. It knows the prefix for one reason: to tell a reader who pasted the wrong credential which one this field wants, instead of answering with the refusal it gives a wrong secret.
const LaunchPath = "/launch"
LaunchPath is the route that exchanges a launch ticket for a console session. It lives here rather than in the router because the CLI writes URLs that point at it and the router serves it, and a path spelled in two places is a path that can be spelled two ways.
const TokenCommand = "starport auth token" // #nosec G101 -- A command line for an operator to type, not credential material.
TokenCommand names the command that prints the local admin token. The refusal above points at it, and the console shows it, so both say the same words.
Variables ¶
var ( // ErrIdentityProviderNotConfigured reports the shipped state: the identity // grant is registered and no provider fills it. // // It is a distinct error rather than ErrGrantUnknown because the two mean // different things to whoever is reading. "No such grant" says this gateway // has never heard of identity sign-in; this says the seam is here and the // deployment has not filled it, which is an operator's answer to give. ErrIdentityProviderNotConfigured = errors.New( "no identity provider is configured for this gateway", ) // ErrIdentitySubjectMissing reports a provider that authenticated a caller // without naming one. A session from this grant exists to carry a person, so // an empty subject is a broken provider rather than an anonymous success. ErrIdentitySubjectMissing = errors.New( "the identity provider named no subject", ) )
var ( // ErrTokenRejected reports a value that is not this machine's local admin // token. It is the answer to a wrong secret, an empty one, and a // well-formed guess alike, for the reason ErrBadSignature is: distinguishing // them would say whether the guess had the right shape. ErrTokenRejected = errors.New("the value is not this machine's local admin token") // ErrGatewayAPIKeyPresented reports a gateway API key offered where the // local admin token belongs. // // It is deliberately a distinct answer. The two credentials are different // ideas — one authenticates an account's inference request, the other is a // claim about sitting at this machine — and a reader who has confused them // learns nothing from a refusal that also covers a wrong secret. Naming the // mistake narrows no search space, because the prefix the reader typed is // already public. ErrGatewayAPIKeyPresented = errors.New( "that is a gateway API key; this field takes the local admin token from " + TokenCommand, ) // ErrRemoteTokenRefused reports a pasted token presented from somewhere // other than this machine while the token is the one first boot printed. ErrRemoteTokenRefused = errors.New( "a local admin token is only accepted from this machine until it has been rotated", ) )
var ( // ErrSessionExpired reports a session this gateway signed but will no // longer honour. ErrSessionExpired = errors.New("the console session has expired") // ErrSessionMalformed reports a correctly signed session whose payload this // version cannot read. ErrSessionMalformed = errors.New("the console session is not a session record") )
var ( // ErrTicketExpired reports a ticket this gateway signed but will no longer // honour. ErrTicketExpired = errors.New("the launch ticket has expired") // ErrTicketUsed reports a ticket that already opened a session. ErrTicketUsed = errors.New("the launch ticket has already been used") // ErrTicketMalformed reports a correctly signed ticket whose payload this // version cannot read. ErrTicketMalformed = errors.New("the launch ticket is not a ticket record") )
var ErrAccountResolverNotConfigured = errors.New(
"no account resolver is configured for this gateway",
)
ErrAccountResolverNotConfigured reports an account-scoped question this deployment cannot answer: an identity session exists but nothing filled the resolver slot.
var ErrBadSignature = errors.New("the value was not signed by this machine's local admin token")
ErrBadSignature reports a value this machine's local admin token did not sign. It is the answer to a forged value and to a value signed by a token that has since been rotated, and those two cases are deliberately one error: telling a caller which it was would say whether their guess had the right shape.
var ErrCorruptRecord = errors.New("the local admin token file is not a token record")
ErrCorruptRecord reports a token file this binary cannot read as a record.
var ErrGrantUnknown = errors.New("no such console session grant")
ErrGrantUnknown reports a grant kind this gateway does not register. It answers a request for a grant that does not exist and a session cookie whose recorded grant this version cannot read.
var ErrNotFound = errors.New("no local admin token exists")
ErrNotFound reports that no token has been minted on this machine.
var ErrPathRequired = errors.New("a local admin token path is required")
ErrPathRequired reports a store built without a token file path.
var ErrUnsupportedVersion = errors.New("the local admin token file uses an unsupported version")
ErrUnsupportedVersion reports a record written by a different binary.
Functions ¶
func AllowsExposure ¶
AllowsExposure reports whether a gateway bound to bindHost may serve this token.
It is the same shape as the AON6 authentication tripwire and reuses its loopback rule rather than restating it: on a loopback address the only callers are already on this machine, and holding the token proves nothing they could not do anyway. On an address the network can reach, the token becomes a credential, and a first-boot secret that has been sitting in a terminal is not one.
The way out is a rotation, not an acknowledgment flag. An operator who acknowledges the risk still has the compromised value; an operator who rotates has a secret that was never printed at boot.
func BrowsableBase ¶
BrowsableBase is the URL a browser on this machine should open to reach a gateway bound to host:port.
A gateway that binds every interface has no address of its own, and 0.0.0.0 or :: in an address bar is a URL a person cannot reason about even where a browser resolves it. So an unspecified bind becomes loopback, which is the interface the browser running this command is on.
func ClearedSessionCookies ¶
ClearedSessionCookies expire both cookies. The gateway sends them when it refuses a session cookie it once issued, so a browser holding a cookie from a rotated token stops presenting it and the console stops claiming to hold a session.
func LaunchURL ¶
LaunchURL puts a ticket on a base URL.
The ticket travels in the query string, which is the one place this package otherwise refuses to put a credential. It is the exception a browser forces: nothing else survives a person clicking a link. Everything about a ticket is built for that exposure — it expires in TicketTTL, it works once, and the route redirects so it leaves the address bar as it is spent.
func MintTicket ¶
MintTicket issues a one-time ticket that /launch exchanges for a console session.
It is a pure function of the token, so the CLI mints one from the token file without asking the gateway. That matters for the command an operator runs before the gateway is listening, and it keeps the credential out of a request that would have had to be authenticated by something.
func SessionCookies ¶
SessionCookies returns the pair a successful launch sets: the credential and the marker the console reads.
SameSite is Lax rather than Strict. The console is reached by following a link the CLI printed, and Strict withholds the cookie on exactly that navigation, so an operator would land on the console signed out and the launch would appear to have failed.
Secure is set from the scheme of the request that redeemed the ticket rather than assumed. A gateway behind TLS should mark the cookie Secure; the loopback console is plain HTTP, and a Secure cookie there is one a browser silently discards.
func TicketPrefix ¶
TicketPrefix is the leading fragment of a ticket that is safe to log. A whole ticket in a log line is a credential in a log line for as long as it lives.
Types ¶
type AccountResolver ¶
type AccountResolver interface {
ReachableAccounts(ctx context.Context, subject string) ([]string, error)
}
AccountResolver is the contract the composition root fills so an identity session can resolve its reachable accounts. The subject is the one the session carries; the answer is the account IDs the subject's grants reach. It lives here for the same reason IdentityProvider does: the gate decides what a session means, and how the mapping is stored is the filler's problem.
type Gate ¶
type Gate struct {
// contains filtered or unexported fields
}
Gate is the running gateway's half of the browser flow: it mints console sessions through its registered grants and verifies them against one local admin token.
It exists so the secret has one holder inside the server. The HTTP adapter carries a Gate rather than a Token, so no configuration struct, dependency struct, or controller field holds the credential, and nothing downstream can print it by printing itself.
The grants are a registry rather than a switch on a kind. A caller asks for a grant by name and gets a refusal for one that is not registered, which is how the identity grant can ship present and inert: its absence is a value the gate returns rather than a branch nobody wrote.
The token is the one read at startup. A rotation writes a new file and does not reach a running process, which is what `starport auth rotate` says out loud: the sessions this Gate issued keep working until the gateway restarts, and then none of them do.
func NewGate ¶
NewGate returns a gate over one token. A zero Token yields a gate that refuses everything, because an unvalidatable token signs nothing.
bindHost is the address the gateway serves on. It is the fallback origin for a grant that judges where a caller is, used when the caller is in-process and so has no remote address of its own.
func (*Gate) Generation ¶
Generation reports which token this gate holds. It is safe to log and is the value an operator compares against `starport auth status` when a session stops working after a rotation.
func (*Gate) MintSession ¶
func (g *Gate) MintSession( kind GrantKind, request GrantRequest, now time.Time, ) (string, Session, error)
MintSession runs one grant over one request and returns the cookie value for the session it opens.
Every caller-facing path into a console session goes through here, so the cookie shape, the lifetime, and the recorded grant have one origin. Every rejection is one of this package's exported errors.
func (*Gate) MintTicket ¶
MintTicket issues a launch ticket from the token this gate holds. It is what an in-process launch uses — `starport dev` already has the running gateway, so it has no reason to go back to the file.
func (*Gate) Redeem ¶
Redeem spends a launch ticket and returns the cookie value for the session it opens. It is the ticket grant under the name the launch route has always called it.
func (*Gate) SessionAccounts ¶
SessionAccounts reports which accounts a session may act for. The second result says whether the session is account-scoped at all: a machine-local session is the operator's own admission, answers false, and is bounded by nothing here. An identity session answers true with the accounts its grants reach — possibly none.
func (*Gate) UseAccountResolver ¶
func (g *Gate) UseAccountResolver(resolver AccountResolver)
UseAccountResolver fills the account-resolution slot, the way UseIdentityProvider fills the identity grant's. Only the composition root calls it, and only when identity is configured.
func (*Gate) UseIdentityProvider ¶
func (g *Gate) UseIdentityProvider(provider IdentityProvider)
UseIdentityProvider fills the identity grant's slot. It is the one way a provider reaches the grant, so a deployment where nothing calls it keeps the inert refusal, and a deployment where the composition root does gets real sign-in through the same registered grant.
type Grant ¶
type Grant interface {
// Kind reports which grant this is. The value reaches the session, so it
// has to agree with the key the gate registered it under.
Kind() GrantKind
// Mint turns a request into a cookie value and the session it stands for.
Mint(request GrantRequest, now time.Time) (string, Session, error)
}
Grant turns one caller's claim into a console session.
Every implementation ends at IssueSession. That is the point of the interface: a grant chooses what to believe, and nothing else. It does not choose the cookie shape, the session lifetime, or the refusal text, because a grant that could choose those would eventually choose them differently.
type GrantKind ¶
type GrantKind string
GrantKind names one registered way to mint a console session.
The three kinds are not variations on a theme. A launch ticket and the local admin token are claims about where the caller is: both say "this browser can reach something only a process on this machine could hand it", and neither says who is holding it. An identity provider says the opposite — it names a person and says nothing about the machine.
The distinction is why this package spends a type on it. A session that records which kind minted it can be logged, tested, and later restricted without a second lookup, and the vocabulary keeps the word "sign in" attached to the only grant that earns it.
const ( // GrantTicket is the one-time launch ticket the CLI hands the browser. GrantTicket GrantKind = "ticket" // GrantLocalToken is the local admin token pasted into the console. GrantLocalToken GrantKind = "local-token" // GrantIdentity is the seam an identity provider fills. No provider ships, // and the grant refuses until one is configured. It is registered rather // than absent so that adding a provider is filling a slot instead of // reopening this seam, and so its refusal is a tested state rather than a // comment. GrantIdentity GrantKind = "identity" )
type GrantRequest ¶
type GrantRequest struct {
// Claim is whatever that grant reads: a ticket, a pasted secret, or a
// provider's callback code.
Claim string
// RemoteHost is the host the request arrived from, without a port. It is
// empty for an in-process caller, which a grant reads as "this machine".
RemoteHost string
}
GrantRequest is everything a grant may know about the caller.
It carries a host string rather than an *http.Request on purpose. A grant decides what to believe about a claim; deciding it from headers, cookies, or a URL would make this package a second HTTP layer, and the one fact a grant genuinely needs about the transport is where the caller is.
type IdentityProvider ¶
type IdentityProvider interface {
// Authenticate turns a provider's callback claim into the subject it names.
// The error is returned to the caller wrapped, never inspected: a provider
// knows why it refused and this package does not.
Authenticate(claim string) (string, error)
}
IdentityProvider is the contract an enterprise deployment fills.
It is one method because that is the whole of what this package needs to know. Where the claim came from — an OIDC authorization code, a SAML assertion, a header a trusted proxy set — is the provider's problem, and a provider that made this package understand any of it would put protocol details in the layer that decides what to believe.
What a provider must supply is a stable subject: the identifier that will be the same person on the next sign-in. It reaches Session.Subject and is signed into the cookie, so it must be an identifier a deployment is willing to have in a browser and in a log — a provider's subject claim rather than a name or an address.
What a provider must not do is decide the session. Lifetime, cookie shape, and refusal handling belong to this package for every grant, so an identity session cannot quietly outlive a machine-local one.
type Session ¶
type Session struct {
IssuedAt time.Time
ExpiresAt time.Time
// Grant is the kind that minted this session. It is signed with the rest,
// so a browser cannot relabel its own admission.
Grant GrantKind
// Subject is who an identity provider said the caller is, and is empty for
// every other grant. The pairing is exact in both directions and enforced
// at issue and at verify: a grant that claims to know who you are has to
// say who, and a grant that only knows where you are may not claim more.
Subject string
}
Session is a browser a grant admitted on this machine.
It names no account for the same reason a ticket does not: the two grants that ship both claim "this browser proved it could reach something only a process on this machine could hand it", and the identity that claim maps to is the gateway's decision, not the cookie's. An identity grant would be the one that changes that, which is why the session records which grant minted it rather than treating them all as the same admission.
func IssueSession ¶
IssueSession mints the cookie value for a browser one grant admitted.
It is unexported behavior in every sense that matters: the grants in this package are its only callers, and a caller that reached it directly would be minting a session no grant vouched for.
func VerifySession ¶
VerifySession reports the session a cookie value stands for.
A rotation of the local admin token changes the signing key, so every cookie issued under the old secret fails here with ErrBadSignature. That is the whole revocation mechanism: there is no session list to clear, and no window in which a gateway holding the new token still honours the old sessions.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the token file and the rules for touching it safely.
Every operation takes an exclusive lock on a sibling lock file, and every write lands through a temporary file and a rename. The lock keeps two processes from minting different tokens on the same first boot; the rename keeps a reader from ever seeing half a record, including a reader that is not holding the lock at all.
func NewStore ¶
NewStore opens the token file at path. The path is not read here, because a missing file is the ordinary first-boot state and not a failure to open.
func (*Store) Load ¶
Load reads the current token. It returns ErrNotFound when nothing has been minted, which is a state a caller can act on rather than an error.
func (*Store) LoadOrMint ¶
LoadOrMint reads the current token, minting one if the machine has none. The second return value reports whether this call is the one that minted it, so a first boot can say so and every later boot can stay quiet.
func (*Store) Path ¶
Path is the token file this store owns. Commands print it so an operator can find, inspect, or delete the credential without guessing at a platform convention.
func (*Store) Peek ¶
Peek reads the current token without taking the file lock and without creating anything on disk — no directory, no lock file. Writes land through an atomic rename, so a plain read never sees a half-written record; what a peeking reader gives up is only the guarantee that the token is not rotated mid-call. A development gateway uses it, because that gateway promises to leave the disk exactly as it found it.
type Tickets ¶
type Tickets struct {
// contains filtered or unexported fields
}
Tickets enforces the one-time half of a launch ticket.
The signature and the expiry live in the ticket itself; single use cannot, because a value that proves it has not been spent would have to be spent to prove it. So the gateway remembers the nonces it has honoured, and only until each one expires: the set never grows past the tickets minted in the last TicketTTL, which is a handful even on a machine whose operator is holding down the key.
The set is per process. A restart forgets, so a ticket minted in the last TicketTTL and never spent could be spent once against the new process. That window is the ticket's own lifetime and it closes on its own, which is a smaller cost than persisting a record for every URL an operator prints.
func (*Tickets) Redeem ¶
Redeem checks a ticket and spends it. Every rejection is one of the exported errors, and a caller that turns them all into the same HTTP answer is doing the right thing: which check failed is a fact about the gateway's state that a caller holding a bad ticket has no business learning.
type Token ¶
type Token struct {
// Version is the record layout. It is first so a hand-read file leads with
// the field that decides whether the rest means anything.
Version int `json:"version"`
// Secret is the value a caller presents.
Secret string `json:"secret"`
// Generation counts how many times this machine has minted a token. It is
// what an operator compares after a rotation to see that it took.
Generation uint64 `json:"generation"`
// IssuedAt is when this secret was minted.
IssuedAt time.Time `json:"issued_at"`
// Scope names what the token authorizes.
Scope string `json:"scope"`
// RotatedAt is when an operator last replaced the secret deliberately. A
// nil value means never: the token is still the one first boot minted.
RotatedAt *time.Time `json:"rotated_at,omitempty"`
}
Token is one local admin credential and everything an operator needs to judge it.
func Mint ¶
Mint creates a new token at the given generation. The caller decides the generation, because only the caller knows whether this is a first boot or a rotation of something already on disk.
func (Token) Authorizes ¶
Authorizes reports whether candidate is this token. The comparison is constant time, because the alternative leaks the secret one byte at a time to anyone who can time the answer.
func (Token) Redacted ¶
Redacted is the token with its secret removed, for anything that reports on the token rather than presents it.
func (Token) Rotated ¶
Rotated reports whether an operator has replaced the first-boot secret.
A never-rotated token is the one this machine printed when it first started. That value has been in a terminal, and a terminal is scrollback, a tmux buffer, a screen share, and a CI log. It is safe where it was born and nowhere else.