Add the Secrets Manager REST client with a fake-server test suite

Public surface Authenticate/GetSecret/ListSecrets/ListProjects: OAuth
client_credentials against /identity/connect/token (with the
encrypted_payload organization-key unwrap), refresh-before-expiry, and
bearer reads under /api with in-memory decryption. Errors are fixed
reason enums that can never embed material. Everything is tested against
an in-process fake Secrets Manager speaking the same protocol and crypto
(auth failure, expiry, refresh, missing secrets, malformed payloads,
tampered MACs, plaintext mode, and redaction sweeps over every error
path); the real vault is never contacted.
This commit is contained in:
2026-08-29 00:08:31 -05:00
parent fd0f22ca2e
commit c94c0b1de4
5 changed files with 1475 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
package bitwarden
import (
"context"
"fmt"
"sync"
"time"
)
// refreshSkew is how long before expiry a token refreshes itself: read
// calls made inside the skew window refresh first so a token never dies
// mid-use. Tokens issued with a TTL shorter than the skew refresh on
// every call (fake-server tests rely on this).
const refreshSkew = 30 * time.Second
// Token is an authenticated Secrets Manager session. It lives in memory
// only, is never persisted by this package, and its String() form is safe
// for logs (it renders expiry and ids, never the token value).
type Token struct {
AccessToken string
RefreshToken string
TokenType string
Scope string
ExpiresAt time.Time
// AccountID is the machine account's uuid (the credential's client
// id); Organization is the JWT's organization claim when present.
AccountID string
Organization string
baseURL string
clientID string
payloadKey *SymmetricKey // credential-derived; unwraps encrypted_payload
orgKey *SymmetricKey // decrypted organization key; decrypts secrets
httpc httpClient
now func() time.Time
mu sync.Mutex
}
// Expired reports whether the access token is past (or within the refresh
// skew of) expiry.
func (t *Token) Expired() bool {
if t == nil {
return true
}
return t.nowT().Add(refreshSkew).After(t.ExpiresAt)
}
// String renders a log-safe summary. It deliberately has no way to
// produce the access token, refresh token, or any key material.
func (t *Token) String() string {
if t == nil {
return "bitwarden token (none)"
}
org := t.Organization
if len(org) >= 8 {
org = org[:8] + "..."
}
return fmt.Sprintf("bitwarden token (type %s, expires %s, org %s)", t.TokenType, t.ExpiresAt.UTC().Format(time.RFC3339), org)
}
// Zero wipes key material and clears token strings. Call when the session
// is no longer needed.
func (t *Token) Zero() {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.AccessToken = ""
t.RefreshToken = ""
t.payloadKey.Zero()
t.payloadKey = nil
t.orgKey.Zero()
t.orgKey = nil
}
// Refresh exchanges the refresh token for a fresh access token in place.
// Key material is retained (the organization key does not rotate on
// refresh).
func (t *Token) Refresh(ctx context.Context) error {
t.mu.Lock()
defer t.mu.Unlock()
if t.RefreshToken == "" {
return fmt.Errorf("%w: no refresh token held", ErrTokenExpired)
}
return t.refreshLocked(ctx)
}
// require is the guard for read calls: token present, not expired.
func (t *Token) require(ctx context.Context) error {
return t.ensureFresh(ctx)
}
// ensureFresh refreshes before expiry when a refresh token is held.
// Without one, an expired token is a hard error telling the caller to
// re-authenticate.
func (t *Token) ensureFresh(ctx context.Context) error {
if t == nil {
return fmt.Errorf("bitwarden: no token")
}
t.mu.Lock()
defer t.mu.Unlock()
if t.nowT().Add(refreshSkew).After(t.ExpiresAt) {
if t.RefreshToken == "" {
return fmt.Errorf("%w: re-authenticate", ErrTokenExpired)
}
return t.refreshLocked(ctx)
}
return nil
}
func (t *Token) refreshLocked(ctx context.Context) error {
form := "grant_type=refresh_token&refresh_token=" + urlEscape(t.RefreshToken) + "&client_id=" + urlEscape(t.clientID)
var body tokenResponseBody
if err := t.httpc.postForm(ctx, t.baseURL+"/identity/connect/token", form, &body); err != nil {
return err
}
if body.AccessToken == "" || body.ExpiresIn == 0 {
return fmt.Errorf("%w: refresh response missing token", ErrMalformedResponse)
}
t.applyTokenBody(&body)
return nil
}
func (t *Token) nowT() time.Time {
if t.now != nil {
return t.now()
}
return time.Now()
}
// decryptField resolves one wire field: EncString-shaped values are
// decrypted with the organization key (failing closed), anything else is
// passed through as plaintext so plaintext-mode test servers and
// plaintext gateways work without weakening the encrypted path. label
// names the field in errors (names only, never contents).
func (t *Token) decryptField(label, field string) (string, error) {
if field == "" {
return "", nil
}
if enc, ok := ParseEncString(field); ok {
pt, err := enc.Decrypt(t.orgKey)
if err != nil {
return "", fmt.Errorf("%w: %s", err, label)
}
return string(pt), nil
}
return field, nil
}