Skip to content
2 changes: 2 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -2170,6 +2170,8 @@ FLAG basecamp auth token --no-stats type=bool
FLAG basecamp auth token --profile type=string
FLAG basecamp auth token --project type=string
FLAG basecamp auth token --quiet type=bool
FLAG basecamp auth token --session-id type=string
FLAG basecamp auth token --session-label type=string
FLAG basecamp auth token --stats type=bool
FLAG basecamp auth token --stored type=bool
FLAG basecamp auth token --styled type=bool
Expand Down
10 changes: 10 additions & 0 deletions internal/auth/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ type agentMint struct {
scope string
resource string
client *http.Client

// launch is the session a session mint attributes its token to, or
// nil for the profile's shared token (see agent_launch.go).
launch *agentLaunch
}

// prepareAgentMint is the half of a mint that sends nothing: it checks what
Expand Down Expand Up @@ -324,6 +328,7 @@ func (m *Manager) mintAgentToken(ctx context.Context, mint *agentMint) (*oauth.T
if mint.resource != "" {
form.Set("resource", mint.resource)
}
mint.launch.addTo(form)

reqCtx, cancel := context.WithTimeout(ctx, agentMintTimeout)
defer cancel()
Expand Down Expand Up @@ -422,6 +427,11 @@ func (m *Manager) mintAgentToken(ctx context.Context, mint *agentMint) (*oauth.T
if err := applyTokenLifetime(&token, body); err != nil {
return nil, nil, output.ErrAPI(resp.StatusCode, "minting an agent token: "+err.Error())
}
// Last, so a token that is not bound to the session it was asked for
// never leaves this function, however good it is otherwise.
if err := mint.launch.requireEcho(resp.StatusCode, body); err != nil {
return nil, nil, err
}
return &token, nil, nil
}

Expand Down
258 changes: 258 additions & 0 deletions internal/auth/agent_launch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
package auth

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"regexp"
"strings"
"unicode"
"unicode/utf8"

"github.com/basecamp/basecamp-cli/internal/output"
)

// Agent sessions: one self-token per launch, attributed to that launch.
//
// An agent profile's shared token is cached and served to every process on
// the host, so Basecamp cannot tell one sandboxed box from another: every
// request reads as the agent, from one token. A session mint asks the token
// endpoint for a token of the session's own instead, carrying a random id
// the launcher generated and an optional label it chose. Basecamp stores
// both on the token; its request logs carry the id beside the agent, never
// the label.
//
// The CLI calls this a session; on the wire, and in bc3, it is a launch
// (launch_id, launch_label), because "session" already means the web
// sign-in there.
//
// The contract is "a token bound to that session, or nothing":
//
// - The token is minted fresh on every call and never cached here. The
// caller is the per-launch cache: the sandbox's credential capture holds
// each token for a few minutes inside the launch's own proxy, which dies
// with the launch. Caching here instead would write one keyring entry
// per launch, which nothing would ever clean up, and contend every launch
// on the one credential lock for a token only one launch may use.
//
// - The shared cached token is neither served nor written: a session that
// fell back to it would be indistinguishable from every other process.
//
// - A server that does not echo the session back has not bound the token
// to it — it predates session attribution and ignored the parameters —
// and the token is discarded. Handing it out would be the failure that
// reads as working: attribution silently missing, and once Basecamp can
// stop a session, a token that stopping it would not reach.
//
// - A refusal is reported, never retried without the session.

// launchIDPattern is a session id as bc3 accepts it: 128 random bits as
// canonical lowercase hex.
var launchIDPattern = regexp.MustCompile(`\A[0-9a-f]{32}\z`)

// maxLaunchLabelChars is bc3's bound on a session label, in characters.
const maxLaunchLabelChars = 100

// agentLaunch is the session a mint attributes its token to.
type agentLaunch struct {
id string
label string
}

// ValidateSession checks a session id and label against the rules the
// token endpoint holds them to, so a malformed one is a usage error here
// rather than a refused mint. The label is optional; the id is not.
func ValidateSession(id, label string) error {
if !launchIDPattern.MatchString(id) {
return output.ErrUsageHint("A session id must be 32 lowercase hex characters (128 random bits)",
"Generate one with: openssl rand -hex 16")
}
if label == "" {
return nil
}
if !utf8.ValidString(label) {
return output.ErrUsage("A session label must be valid UTF-8")
}
if strings.TrimSpace(label) == "" {
return output.ErrUsage("A session label must not be blank")
}
if n := utf8.RuneCountInString(label); n > maxLaunchLabelChars {
return output.ErrUsage(fmt.Sprintf("A session label must be at most %d characters (this one is %d)", maxLaunchLabelChars, n))
}
for _, r := range label {
if !labelRune(r) {
return output.ErrUsage(fmt.Sprintf("A session label must not contain control, formatting or line-separator characters (found U+%04X)", r))
}
}
return nil
}

// labelRune reports whether r may appear in a session label: anything but
// the "other" category (control, format, private use, surrogate) and the
// line and paragraph separators — exactly the set bc3 refuses. The label is
// shown to people, so nothing that reorders, hides or breaks the text around
// it. Unassigned code points are deliberately allowed: a host's Unicode
// tables can be newer than this build's or the server's, and a label refused
// over that skew costs the launch its whole token.
func labelRune(r rune) bool {
return !unicode.In(r, unicode.Cc, unicode.Cf, unicode.Co, unicode.Cs, unicode.Zl, unicode.Zp)
}

// addTo puts the session on a client_credentials form. A nil launch is the
// shared token's mint, which sends nothing extra.
func (l *agentLaunch) addTo(form url.Values) {
if l == nil {
return
}
form.Set("launch_id", l.id)
if l.label != "" {
form.Set("launch_label", l.label)
}
}

// errLaunchNotRecorded is the cause of a session mint whose token came back
// without the session on it.
var errLaunchNotRecorded = errors.New("the token endpoint did not record the session")

// requireEcho refuses a token response that does not carry the session it
// was asked for. bc3 echoes launch_id, and launch_label when one was given,
// once it has stored them on the token; a server that ignored them echoes
// nothing.
func (l *agentLaunch) requireEcho(status int, body []byte) error {
if l == nil {
return nil
}
var echoed struct {
ID *string `json:"launch_id"`
Label *string `json:"launch_label"`
}
_ = json.Unmarshal(body, &echoed)

idMatches := echoed.ID != nil && *echoed.ID == l.id
labelMatches := (l.label == "" && echoed.Label == nil) || (echoed.Label != nil && *echoed.Label == l.label)
if idMatches && labelMatches {
return nil
}

// Say which way the echo missed. Only a server that echoed no id at
// all is one that predates sessions; one that echoed this id records
// them, and what it got wrong is the label.
msg := fmt.Sprintf("Basecamp minted a token but did not bind it to session %s, so it was discarded", l.id)
hint := "A session token needs a Basecamp that records agent sessions; without one, leave out --session-id and --session-label"
switch {
case echoed.ID == nil:
msg += ": this Basecamp predates agent session attribution"
case !idMatches:
msg += ": Basecamp answered for a different session"
hint = "Run it again; if it keeps answering for another session, report it"
case echoed.Label == nil:
msg += ": Basecamp recorded the session but did not record the label"
hint = "Leave out --session-label to mint for the session alone"
default:
msg += ": Basecamp recorded a different label for the session"
hint = "Leave out --session-label to mint for the session alone"
}
e := output.ErrAPI(status, msg)
e.Hint = hint
e.Cause = errLaunchNotRecorded
return e
}

// SessionAccessToken mints a fresh self-token for one agent session from
// the active profile's stored agent credential, and returns it without
// storing it. See the top of this file for why it is never cached and why
// a server that does not bind it to the session gets no token at all.
//
// Holds are honored and written exactly as the shared mint's are, because
// they are verdicts on the client secret both mints present: a refused or
// rate-limited secret is answered locally here too, and a refusal of the
// secret seen here is remembered for every later mint. A refusal of the
// request itself — the session's parameters, above all — is not about the
// secret and holds nothing, so one malformed launch cannot stop the rest.
func (m *Manager) SessionAccessToken(ctx context.Context, id, label string) (string, error) {
if err := ValidateSession(id, label); err != nil {
return "", err
}

m.mu.Lock()
defer m.mu.Unlock()

credKey := m.credentialKey()
creds, err := m.store.LoadContext(ctx, credKey)
Comment thread
jeremy marked this conversation as resolved.
if err != nil {
return "", m.unreadable("No stored credentials for", credKey, err)
}
m.remember(creds)
if creds.OAuthType != oauthTypeAgent {
return "", output.ErrUsage(fmt.Sprintf(
"A session token is minted from an agent's own credential, and %s holds a person's login; run without --session-id", credKey))
}

launch := &agentLaunch{id: id, label: label}
mint, err := m.prepareAgentMint(creds)
if err != nil {
return "", forSession(launch, m.hintLogin(err))
}
mint.launch = launch

// No credential lock across the round trip: nothing is written on
// success, so there is nothing for concurrent launches to be exclusive
// with. Only a hold is written, and that under the lock, as the shared
// path writes it.
token, hold, err := m.mintAgentToken(ctx, mint)
if err != nil {
if hold != nil {
err = m.rememberSessionHold(ctx, credKey, hold, err)
}
return "", forSession(launch, m.hintLogin(err))
}
return token.AccessToken, nil
}

// rememberSessionHold writes the hold a session mint's refusal leaves, under
// the credential lock, and returns what the mint answers with: its own
// refusal, or the verdict that stood instead.
//
// With no lock across the round trip, another launch's verdict on the same
// secret can land while this mint is in flight, and one that outlasts this
// mint's stands (rememberRenewalHold). Its answer is then this mint's too,
// so the wait a caller is told is the one the next mint will meet.
func (m *Manager) rememberSessionHold(ctx context.Context, credKey string, hold *RenewalHold, refusal error) error {
var stood *Credentials
lockErr := m.store.withKeyLock(ctx, credKey, func() error {
stood = m.rememberRenewalHold(credKey, hold)
return nil
})
if lockErr != nil {
m.warnf("warning: could not lock the credential for %s to remember the token endpoint's refusal, so the next command will ask again: %v", credKey, lockErr)
}
if stood == nil {
return refusal
}
held := m.heldRenewal(stood)
if held == nil {
return refusal
}
return m.agentRemedy(held, stood.ClientID, stood.Scope)
}

// forSession names the session in a failed session mint's message, keeping
// its class, hint and cause.
func forSession(l *agentLaunch, err error) error {
if errors.Is(err, errLaunchNotRecorded) {
// Already says which session.
return err
}
var e *output.Error
if !errors.As(err, &e) {
return fmt.Errorf("session %s: %w", l.id, err)
}
named := *e
named.Message = fmt.Sprintf("Session %s: %s", l.id, e.Message)
if named.Cause == nil {
named.Cause = err
}
return &named
}
Loading
Loading