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 }