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.
152 lines
4.2 KiB
Go
152 lines
4.2 KiB
Go
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
|
|
}
|