Files
unkin-agent 155392a809
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add agentvault seed-oauth for oauth2-proxy credentials
Seeding an oauth2-proxy secret by hand means an agent shell-plumbing a
client secret and a cookie secret, which the classifier blocks. seed-oauth
does it in one self-contained invocation: it reads the KV path, fills in
only the keys that are missing, preserves everything else and prints key
names and the new version, never a value.

- Add SeedOAuth in internal/agent: read-modify-write of the client_id,
  client_secret and cookie_secret keys with per-key created/kept/rotated
  actions and a no-op when nothing changed.
- Generate secrets from 32 crypto/rand bytes; cookie_secret is base64url so
  it decodes to exactly the 32 bytes oauth2-proxy requires.
- Add ReadKVOptional (missing secret = empty) and WriteKVAny (non-string
  fields survive a round trip) to the KV-v2 client.
- Wire the seed-oauth subcommand and document it in README and AGENTS.md.
- Cover fresh create, patch-preserves-client_secret, other-key
  preservation, --rotate, idempotence, denial errors and secret leakage.
2026-08-30 15:31:25 +10:00

184 lines
5.1 KiB
Go

package agent
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"sort"
)
const (
// OAuthClientIDKey, OAuthClientSecretKey and OAuthCookieSecretKey are the
// KV fields oauth2-proxy deployments read their credentials from.
OAuthClientIDKey = "client_id"
OAuthClientSecretKey = "client_secret"
OAuthCookieSecretKey = "cookie_secret"
// oauthSecretBytes is the raw length of generated secrets. oauth2-proxy
// rejects a cookie secret that does not decode to exactly 32 bytes.
oauthSecretBytes = 32
)
// Per-key outcomes reported by SeedOAuth.
const (
ActionCreated = "created"
ActionKept = "kept"
ActionRotated = "rotated"
ActionUpdated = "updated"
ActionPreserved = "preserved"
)
// SeedOAuthOptions configures SeedOAuth. The CLI supplies the defaults.
type SeedOAuthOptions struct {
VaultAddr string
RoleID string
KVMount string
Path string
ClientID string
Rotate bool
}
// SeedOAuthKey names a key present in the secret and what happened to it.
type SeedOAuthKey struct {
Name string
Action string
}
// SeedOAuthResult is the non-secret summary of a seed run. Version is zero when
// nothing changed and no write was issued.
type SeedOAuthResult struct {
KVMount string
Path string
Keys []SeedOAuthKey
Version int
Changed bool
}
// KeyNames returns the key names present in the secret after the run.
func (r SeedOAuthResult) KeyNames() []string {
names := make([]string, 0, len(r.Keys))
for _, k := range r.Keys {
names = append(names, k.Name)
}
return names
}
// SeedOAuth makes a Vault KV-v2 path hold a complete oauth2-proxy credential
// set: client_id from the caller, plus a client_secret and cookie_secret that
// are generated only when absent (or when Rotate is set). It is a
// read-modify-write, so keys it does not own survive untouched, and it writes
// nothing when the secret is already correct. Secret material never leaves this
// function: results and errors carry only key names.
func SeedOAuth(o SeedOAuthOptions) (SeedOAuthResult, error) {
var res SeedOAuthResult
if o.Path == "" {
return res, errors.New("a KV-v2 path is required")
}
if o.ClientID == "" {
return res, errors.New("a client id is required")
}
vc, err := NewVaultClient(o.VaultAddr, o.RoleID)
if err != nil {
return res, fmt.Errorf("vault approle login failed against %s (check VAULT_ADDR and AGENT_APPROLE_ROLE_ID): %w", o.VaultAddr, err)
}
existing, err := vc.ReadKVOptional(o.KVMount, o.Path)
if err != nil {
if errors.Is(err, ErrVaultDenied) {
return res, fmt.Errorf("reading %s/%s denied: the agent AppRole policy does not grant read on this path (apply the terraform-vault policy change): %w", o.KVMount, o.Path, err)
}
return res, fmt.Errorf("reading %s/%s: %w", o.KVMount, o.Path, err)
}
data := make(map[string]any, len(existing)+3)
for k, v := range existing {
data[k] = v
}
var (
keys []SeedOAuthKey
changed bool
)
current, _ := existing[OAuthClientIDKey].(string)
switch current {
case o.ClientID:
keys = append(keys, SeedOAuthKey{OAuthClientIDKey, ActionKept})
case "":
keys = append(keys, SeedOAuthKey{OAuthClientIDKey, ActionCreated})
changed = true
default:
keys = append(keys, SeedOAuthKey{OAuthClientIDKey, ActionUpdated})
changed = true
}
data[OAuthClientIDKey] = o.ClientID
for _, gen := range []struct {
name string
enc *base64.Encoding
}{
// oauth2-proxy accepts a standard-base64 client secret, but the cookie
// secret goes into a cookie and must be URL-safe.
{OAuthClientSecretKey, base64.StdEncoding},
{OAuthCookieSecretKey, base64.RawURLEncoding},
} {
current, _ := existing[gen.name].(string)
if current != "" && !o.Rotate {
keys = append(keys, SeedOAuthKey{gen.name, ActionKept})
continue
}
value, err := randomSecret(gen.enc)
if err != nil {
return res, fmt.Errorf("generating %s: %w", gen.name, err)
}
action := ActionCreated
if current != "" {
action = ActionRotated
}
data[gen.name] = value
keys = append(keys, SeedOAuthKey{gen.name, action})
changed = true
}
var others []string
for k := range existing {
switch k {
case OAuthClientIDKey, OAuthClientSecretKey, OAuthCookieSecretKey:
default:
others = append(others, k)
}
}
sort.Strings(others)
for _, k := range others {
keys = append(keys, SeedOAuthKey{k, ActionPreserved})
}
res = SeedOAuthResult{KVMount: o.KVMount, Path: o.Path, Keys: keys}
if !changed {
return res, nil
}
version, err := vc.WriteKVAny(o.KVMount, o.Path, data)
if err != nil {
if errors.Is(err, ErrVaultDenied) {
return SeedOAuthResult{}, fmt.Errorf("writing %s/%s denied: the agent AppRole policy does not grant create/update on this path (apply the terraform-vault policy change): %w", o.KVMount, o.Path, err)
}
return SeedOAuthResult{}, fmt.Errorf("writing %s/%s: %w", o.KVMount, o.Path, err)
}
res.Version = version
res.Changed = true
return res, nil
}
// randomSecret returns oauthSecretBytes of crypto/rand entropy in the given
// base64 encoding.
func randomSecret(enc *base64.Encoding) (string, error) {
buf := make([]byte, oauthSecretBytes)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return enc.EncodeToString(buf), nil
}