155392a809
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.
184 lines
5.1 KiB
Go
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
|
|
}
|