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 }