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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user