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,263 @@
|
|||||||
|
package bitwarden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpClient is the narrow transport the client needs. The fake server in
|
||||||
|
// tests plugs in here; the production implementation is a plain
|
||||||
|
// *http.Client with a 30-second per-request budget.
|
||||||
|
type httpClient interface {
|
||||||
|
postForm(ctx context.Context, endpoint, form string, out any) error
|
||||||
|
getJSON(ctx context.Context, endpoint, bearer string, out any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type stdHTTPClient struct{ c *http.Client }
|
||||||
|
|
||||||
|
func (s stdHTTPClient) postForm(ctx context.Context, endpoint, form string, out any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: bad endpoint", ErrUnreachable)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
return s.do(req, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stdHTTPClient) getJSON(ctx context.Context, endpoint, bearer string, out any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: bad endpoint", ErrUnreachable)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||||
|
return s.do(req, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stdHTTPClient) do(req *http.Request, out any) error {
|
||||||
|
resp, err := s.c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Transport errors can embed URLs and peer text; drop them.
|
||||||
|
return ErrUnreachable
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return ErrUnreachable
|
||||||
|
}
|
||||||
|
return handleResponse(resp.StatusCode, body, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResponse maps one HTTP exchange to typed errors. Response BODIES
|
||||||
|
// are never surfaced: a server echo is assumed to be able to contain
|
||||||
|
// material.
|
||||||
|
func handleResponse(status int, body []byte, out any) error {
|
||||||
|
switch {
|
||||||
|
case status == http.StatusOK:
|
||||||
|
if err := json.Unmarshal(body, out); err != nil {
|
||||||
|
return fmt.Errorf("%w: body is not valid json", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case status == http.StatusBadRequest || status == http.StatusUnauthorized:
|
||||||
|
var e struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &e) // best effort; codes are sanitized below
|
||||||
|
code := ""
|
||||||
|
if isOAuthCode(e.Error) {
|
||||||
|
code = " (" + e.Error + ")"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w%s", ErrAuthFailed, code)
|
||||||
|
case status == http.StatusNotFound:
|
||||||
|
return ErrSecretNotFound
|
||||||
|
case status >= 500:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrServer, status)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrServer, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOAuthCode allows only the fixed lowercase OAuth error codes into error
|
||||||
|
// strings; any other server text is dropped.
|
||||||
|
func isOAuthCode(s string) bool {
|
||||||
|
if len(s) == 0 || len(s) > 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if !(r >= 'a' && r <= 'z' || r == '_') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlEscape(s string) string { return url.QueryEscape(s) }
|
||||||
|
|
||||||
|
// tokenResponseBody is the union of the machine-account success shapes:
|
||||||
|
// the OAuth fields plus Secrets Manager's encrypted_payload (present on
|
||||||
|
// client_credentials logins; absent on refresh).
|
||||||
|
type tokenResponseBody struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
EncryptedPayload *string `json:"encrypted_payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// orgKeyPayload is the JSON inside encrypted_payload once decrypted.
|
||||||
|
type orgKeyPayload struct {
|
||||||
|
EncryptionKey string `json:"encryptionKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchangeToken performs the client_credentials login and builds a Token
|
||||||
|
// with the organization key unwrapped.
|
||||||
|
func exchangeToken(ctx context.Context, baseURL, clientID, clientSecret string, payloadKey *SymmetricKey) (*Token, error) {
|
||||||
|
form := "grant_type=client_credentials" +
|
||||||
|
"&client_id=" + urlEscape(clientID) +
|
||||||
|
"&client_secret=" + urlEscape(clientSecret) +
|
||||||
|
"&scope=" + urlEscape("api.secrets")
|
||||||
|
httpc := stdHTTPClient{c: &http.Client{Timeout: 30 * time.Second}}
|
||||||
|
var body tokenResponseBody
|
||||||
|
if err := httpc.postForm(ctx, baseURL+"/identity/connect/token", form, &body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if body.AccessToken == "" || body.ExpiresIn == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: token response missing fields", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
t := &Token{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
clientID: clientID,
|
||||||
|
httpc: httpc,
|
||||||
|
}
|
||||||
|
t.applyTokenBody(&body)
|
||||||
|
t.AccountID = clientID
|
||||||
|
|
||||||
|
if body.EncryptedPayload != nil {
|
||||||
|
if payloadKey == nil {
|
||||||
|
return nil, fmt.Errorf("%w: server sent encrypted payload but credential carries no key", ErrDecrypt)
|
||||||
|
}
|
||||||
|
enc, ok := ParseEncString(*body.EncryptedPayload)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: encrypted payload not an encstring", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
pt, err := enc.Decrypt(payloadKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var kp orgKeyPayload
|
||||||
|
if err := json.Unmarshal(pt, &kp); err != nil || kp.EncryptionKey == "" {
|
||||||
|
return nil, fmt.Errorf("%w: encrypted payload not an org key", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
raw, err := b64Decode(kp.EncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: org key not base64", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
orgKey, err := NewSymmetricKey(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: org key length", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
t.orgKey = orgKey
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTokenBody installs a token response, keeping key material and
|
||||||
|
// transport (refresh rotates tokens, not keys).
|
||||||
|
func (t *Token) applyTokenBody(body *tokenResponseBody) {
|
||||||
|
t.AccessToken = body.AccessToken
|
||||||
|
t.TokenType = body.TokenType
|
||||||
|
t.Scope = body.Scope
|
||||||
|
if body.RefreshToken != "" {
|
||||||
|
t.RefreshToken = body.RefreshToken
|
||||||
|
}
|
||||||
|
t.ExpiresAt = t.nowT().Add(time.Duration(body.ExpiresIn) * time.Second)
|
||||||
|
if claims, ok := parseJWTClaims(body.AccessToken); ok {
|
||||||
|
t.Organization = claims.Organization
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireSecret mirrors the server's secret object (camelCase JSON).
|
||||||
|
type wireSecret struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OrganizationID string `json:"organizationId"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
CreationDate string `json:"creationDate"`
|
||||||
|
RevisionDate string `json:"revisionDate"`
|
||||||
|
Read bool `json:"read"`
|
||||||
|
Write bool `json:"write"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireSecretList struct {
|
||||||
|
Data []wireSecret `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireProject struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OrganizationID string `json:"organizationId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreationDate string `json:"creationDate"`
|
||||||
|
RevisionDate string `json:"revisionDate"`
|
||||||
|
Read bool `json:"read"`
|
||||||
|
Write bool `json:"write"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireProjectList struct {
|
||||||
|
Data []wireProject `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Token) listSecrets(ctx context.Context, accountID string) ([]wireSecret, error) {
|
||||||
|
var list wireSecretList
|
||||||
|
if err := t.httpc.getJSON(ctx, t.baseURL+"/api/accounts/"+urlEscape(accountID)+"/secrets", t.AccessToken, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return list.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Token) getSecretByID(ctx context.Context, id string) (*wireSecret, error) {
|
||||||
|
var s wireSecret
|
||||||
|
if err := t.httpc.getJSON(ctx, t.baseURL+"/api/secrets/"+urlEscape(id), t.AccessToken, &s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Token) listProjects(ctx context.Context, accountID string) ([]wireProject, error) {
|
||||||
|
var list wireProjectList
|
||||||
|
if err := t.httpc.getJSON(ctx, t.baseURL+"/api/accounts/"+urlEscape(accountID)+"/projects", t.AccessToken, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return list.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUUID reports whether s looks like a Bitwarden guid (secret names and
|
||||||
|
// ids are distinguishable, so GetSecret can route uuids to the direct
|
||||||
|
// endpoint).
|
||||||
|
func isUUID(s string) bool {
|
||||||
|
if len(s) != 36 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, c := range s {
|
||||||
|
switch i {
|
||||||
|
case 8, 13, 18, 23:
|
||||||
|
if c != '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
isHex := c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
|
||||||
|
if !isHex {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
// Package bitwarden is a plain-REST client for Bitwarden Secrets Manager
|
||||||
|
// machine accounts, written with the Go standard library only. The
|
||||||
|
// official SDK is deliberately not imported: it is source-available under
|
||||||
|
// a license incompatible with this project (see DESIGN.md).
|
||||||
|
//
|
||||||
|
// Wire protocol implemented (verified against a fake Secrets Manager in
|
||||||
|
// tests; live servers attach with zero code change):
|
||||||
|
//
|
||||||
|
// - POST /identity/connect/token — grant_type=client_credentials,
|
||||||
|
// scope=api.secrets, machine client_id/client_secret. The response's
|
||||||
|
// access token is a JWT (claims exp/sub/organization) whose
|
||||||
|
// encrypted_payload carries the organization key, wrapped for the
|
||||||
|
// credential's embedded 16-byte key via HKDF-SHA256.
|
||||||
|
// - POST /identity/connect/token — grant_type=refresh_token for
|
||||||
|
// refresh-before-expiry.
|
||||||
|
// - GET /api/accounts/{account}/secrets, GET /api/secrets/{id},
|
||||||
|
// GET /api/projects/{project}/secrets, GET /api/accounts/{account}/projects
|
||||||
|
// — secrets and project names arrive as EncString cipher strings the
|
||||||
|
// client decrypts in memory with the organization key.
|
||||||
|
//
|
||||||
|
// Security rules enforced by construction and by tests: tokens, keys and
|
||||||
|
// secret values live in memory only, are never persisted, never logged,
|
||||||
|
// and never embedded in error strings; errors carry fixed reason enums
|
||||||
|
// plus non-secret identifiers (uuids, secret names) only.
|
||||||
|
package bitwarden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel error classes. Error() text is fixed-shape and safe to log.
|
||||||
|
var (
|
||||||
|
// ErrInvalidCredentials: credential input malformed or incomplete.
|
||||||
|
ErrInvalidCredentials = errors.New("bitwarden: invalid credentials")
|
||||||
|
// ErrAuthFailed: token exchange or refresh rejected by the server.
|
||||||
|
ErrAuthFailed = errors.New("bitwarden: auth failed")
|
||||||
|
// ErrTokenExpired: access token expired and no refresh token held.
|
||||||
|
ErrTokenExpired = errors.New("bitwarden: token expired")
|
||||||
|
// ErrSecretNotFound: no secret matches the requested name/id.
|
||||||
|
ErrSecretNotFound = errors.New("bitwarden: secret not found")
|
||||||
|
// ErrMalformedResponse: server payload failed parsing or decryption.
|
||||||
|
ErrMalformedResponse = errors.New("bitwarden: malformed response")
|
||||||
|
// ErrDecrypt: cipher string failed integrity check or decryption.
|
||||||
|
ErrDecrypt = errors.New("bitwarden: decrypt failed")
|
||||||
|
// ErrServer: unexpected server status.
|
||||||
|
ErrServer = errors.New("bitwarden: server error")
|
||||||
|
// ErrUnreachable: transport-level failure.
|
||||||
|
ErrUnreachable = errors.New("bitwarden: server unreachable")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credentials describes one machine account. Either AccessToken (the full
|
||||||
|
// "0.<uuid>.<secret>:<key>" credential as printed by Secrets Manager) or
|
||||||
|
// the ClientID+ClientSecret pair must be set. An AccessToken unlocks
|
||||||
|
// end-to-end decryption; a bare pair can only read servers that return
|
||||||
|
// unencrypted payloads (test doubles, plaintext gateways).
|
||||||
|
//
|
||||||
|
// Credential fields are secrets: they are never logged and never included
|
||||||
|
// in error strings.
|
||||||
|
type Credentials struct {
|
||||||
|
// BaseURL is the server root, e.g. "https://vault.bitwarden.com".
|
||||||
|
// Endpoints are derived as BaseURL/identity/connect/token and
|
||||||
|
// BaseURL/api/... . No default is applied by the library; the CLI
|
||||||
|
// defaults it (see the config package).
|
||||||
|
BaseURL string
|
||||||
|
|
||||||
|
// AccessToken is the full machine credential string.
|
||||||
|
AccessToken string
|
||||||
|
|
||||||
|
// ClientID and ClientSecret are the split credential form.
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
|
||||||
|
// HTTPClient overrides the transport (tests, proxies). Optional.
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project is one Secrets Manager project as returned by ListProjects.
|
||||||
|
type Project struct {
|
||||||
|
ID string
|
||||||
|
Name string // decrypted; empty if the server returned none
|
||||||
|
Revision string
|
||||||
|
ReadAccess bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretInfo is one secret's metadata as returned by ListSecrets. Values
|
||||||
|
// are intentionally NOT included: listing is for discovery; use GetSecret
|
||||||
|
// for material.
|
||||||
|
type SecretInfo struct {
|
||||||
|
ID string
|
||||||
|
Name string // decrypted
|
||||||
|
Revision string
|
||||||
|
ReadAccess bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate exchanges machine credentials for an access token. The
|
||||||
|
// returned Token lives in memory only: refresh it via GetSecret's
|
||||||
|
// automatic refresh-before-expiry or Token.Refresh, zero it with
|
||||||
|
// Token.Zero when done. The client_secret is used for this exchange and
|
||||||
|
// then dropped; refreshes use the refresh token.
|
||||||
|
func Authenticate(ctx context.Context, creds Credentials) (*Token, error) {
|
||||||
|
clientID, clientSecret, payloadKey := "", "", (*SymmetricKey)(nil)
|
||||||
|
switch {
|
||||||
|
case creds.AccessToken != "":
|
||||||
|
var err error
|
||||||
|
clientID, clientSecret, payloadKey, err = parseAccessToken(creds.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case creds.ClientID != "" && creds.ClientSecret != "":
|
||||||
|
clientID, clientSecret = creds.ClientID, creds.ClientSecret
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: no access token or client id/secret pair", ErrInvalidCredentials)
|
||||||
|
}
|
||||||
|
if creds.BaseURL == "" {
|
||||||
|
return nil, fmt.Errorf("%w: no server url", ErrInvalidCredentials)
|
||||||
|
}
|
||||||
|
t, err := exchangeToken(ctx, creds.BaseURL, clientID, clientSecret, payloadKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.clientID = clientID
|
||||||
|
t.payloadKey = payloadKey
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecret resolves one secret to its decrypted value. key is either a
|
||||||
|
// secret NAME (looked up across the account's secrets; the first exact
|
||||||
|
// match in sorted-id order wins) or a secret UUID (fetched directly).
|
||||||
|
// The returned string exists in memory only; it is never logged.
|
||||||
|
func GetSecret(ctx context.Context, tok *Token, key string) (string, error) {
|
||||||
|
if tok == nil {
|
||||||
|
return "", fmt.Errorf("bitwarden: no token")
|
||||||
|
}
|
||||||
|
if err := tok.ensureFresh(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if isUUID(key) {
|
||||||
|
s, err := tok.getSecretByID(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tok.decryptField("secret value", s.Value)
|
||||||
|
}
|
||||||
|
secrets, err := tok.listSecrets(ctx, tok.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var match *wireSecret
|
||||||
|
for i := range secrets {
|
||||||
|
name, err := tok.decryptField("secret name", secrets[i].Key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if name == key {
|
||||||
|
m := secrets[i]
|
||||||
|
match = &m
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match == nil {
|
||||||
|
return "", fmt.Errorf("%w: %s", ErrSecretNotFound, key)
|
||||||
|
}
|
||||||
|
if match.Value == "" {
|
||||||
|
s, err := tok.getSecretByID(ctx, match.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
match = s
|
||||||
|
}
|
||||||
|
return tok.decryptField("secret value", match.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSecrets returns the account's secret metadata (names decrypted,
|
||||||
|
// values never fetched into this listing).
|
||||||
|
func ListSecrets(ctx context.Context, tok *Token) ([]SecretInfo, error) {
|
||||||
|
if err := tok.require(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wire, err := tok.listSecrets(ctx, tok.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]SecretInfo, 0, len(wire))
|
||||||
|
for _, s := range wire {
|
||||||
|
name, err := tok.decryptField("secret name", s.Key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, SecretInfo{ID: s.ID, Name: name, Revision: s.RevisionDate, ReadAccess: s.Read})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProjects returns the account's projects (names decrypted).
|
||||||
|
func ListProjects(ctx context.Context, tok *Token) ([]Project, error) {
|
||||||
|
if err := tok.require(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wire, err := tok.listProjects(ctx, tok.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Project, 0, len(wire))
|
||||||
|
for _, p := range wire {
|
||||||
|
name, err := tok.decryptField("project name", p.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, Project{ID: p.ID, Name: name, Revision: p.RevisionDate, ReadAccess: p.Read})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
package bitwarden_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
bw "git.knownelement.com/ukrrs/mopac-bitwarden-go"
|
||||||
|
"git.knownelement.com/ukrrs/mopac-bitwarden-go/internal/fakesm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The entire suite runs against the in-process fake Secrets Manager
|
||||||
|
// (internal/fakesm). No test in this repo ever contacts a real vault.
|
||||||
|
|
||||||
|
// fixture is one configured fake plus the redaction set: strings that must
|
||||||
|
// NEVER appear in any error message produced by the library.
|
||||||
|
type fixture struct {
|
||||||
|
srv *fakesm.Server
|
||||||
|
cred string
|
||||||
|
reds []string // forbidden substrings for errors
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFixture(t *testing.T) *fixture {
|
||||||
|
t.Helper()
|
||||||
|
srv, cred := fakesm.NewServer()
|
||||||
|
srv.Start()
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
s := srv.Secrets[0]
|
||||||
|
return &fixture{
|
||||||
|
srv: srv,
|
||||||
|
cred: cred,
|
||||||
|
reds: []string{
|
||||||
|
srv.ClientSecret,
|
||||||
|
srv.LastAccessToken(), // empty until minted; live values added per-assert
|
||||||
|
s.Value,
|
||||||
|
srv.Secrets[1].Value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forbid asserts err (when non-nil) leaks nothing from the redaction set.
|
||||||
|
func (f *fixture) forbid(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
reds := append([]string{}, f.reds...)
|
||||||
|
if tok := f.srv.LastAccessToken(); tok != "" {
|
||||||
|
reds = append(reds, tok)
|
||||||
|
}
|
||||||
|
if rt := f.srv.LastRefreshToken(); rt != "" {
|
||||||
|
reds = append(reds, rt)
|
||||||
|
}
|
||||||
|
for _, r := range reds {
|
||||||
|
if r != "" && strings.Contains(msg, r) {
|
||||||
|
t.Fatalf("error leaks secret material (%q): %v", mask(r), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mask keeps even the assertion output free of material.
|
||||||
|
func mask(s string) string {
|
||||||
|
if len(s) > 6 {
|
||||||
|
return s[:6] + "..."
|
||||||
|
}
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixture) creds() bw.Credentials {
|
||||||
|
return bw.Credentials{BaseURL: f.srv.BaseURL(), AccessToken: f.cred}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateAndGetSecretHappyPath(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tok, err := bw.Authenticate(ctx, f.creds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate: %v", err)
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
if tok.Organization != f.srv.OrgID {
|
||||||
|
t.Fatalf("organization claim: %s", tok.Organization)
|
||||||
|
}
|
||||||
|
if f.srv.TokenCalls != 1 {
|
||||||
|
t.Fatalf("token calls: %d", f.srv.TokenCalls)
|
||||||
|
}
|
||||||
|
for _, sec := range f.srv.Secrets {
|
||||||
|
got, err := bw.GetSecret(ctx, tok, sec.Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get %s: %v", sec.Name, err)
|
||||||
|
}
|
||||||
|
if got != sec.Value {
|
||||||
|
t.Fatalf("get %s: wrong value", sec.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.srv.RefreshCalls != 0 {
|
||||||
|
t.Fatalf("refresh on a fresh token: %d", f.srv.RefreshCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSecretByID(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tok, _ := bw.Authenticate(ctx, f.creds())
|
||||||
|
defer tok.Zero()
|
||||||
|
got, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by id: %v", err)
|
||||||
|
}
|
||||||
|
if got != f.srv.Secrets[0].Value {
|
||||||
|
t.Fatal("get by id: wrong value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSecretsAndProjects(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tok, _ := bw.Authenticate(ctx, f.creds())
|
||||||
|
defer tok.Zero()
|
||||||
|
secrets, err := bw.ListSecrets(ctx, tok)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list secrets: %v", err)
|
||||||
|
}
|
||||||
|
if len(secrets) != 2 || secrets[0].Name != "redmine-api-key" || secrets[1].Name != "litellm-key" {
|
||||||
|
t.Fatalf("list secrets: %+v", secrets)
|
||||||
|
}
|
||||||
|
for _, s := range secrets {
|
||||||
|
if strings.Contains(s.Name, "2.") && strings.Count(s.Name, ".") >= 3 {
|
||||||
|
t.Fatalf("list secrets returned ciphertext: %q", s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projects, err := bw.ListProjects(ctx, tok)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list projects: %v", err)
|
||||||
|
}
|
||||||
|
if len(projects) != 1 || projects[0].Name != "harness" {
|
||||||
|
t.Fatalf("list projects: %+v", projects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailureTable(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*fakesm.Server, *fixture)
|
||||||
|
call func(context.Context, *fixture) error
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "wrong client secret",
|
||||||
|
mutate: func(s *fakesm.Server, f *fixture) {
|
||||||
|
f.cred = strings.Replace(f.cred, s.ClientSecret, "wrong-secret-entirely", 1)
|
||||||
|
},
|
||||||
|
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
|
||||||
|
wantErr: bw.ErrAuthFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server 500 on token endpoint",
|
||||||
|
mutate: func(s *fakesm.Server, f *fixture) { s.AuthStatusOverride = 500 },
|
||||||
|
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
|
||||||
|
wantErr: bw.ErrServer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed token body",
|
||||||
|
mutate: func(s *fakesm.Server, f *fixture) { s.MalformedTokenBody = true },
|
||||||
|
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
|
||||||
|
wantErr: bw.ErrMalformedResponse,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unreachable server",
|
||||||
|
mutate: func(s *fakesm.Server, f *fixture) {
|
||||||
|
s.Close()
|
||||||
|
},
|
||||||
|
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
|
||||||
|
wantErr: bw.ErrUnreachable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing secret name",
|
||||||
|
call: func(ctx context.Context, f *fixture) error {
|
||||||
|
tok, err := bw.Authenticate(ctx, f.creds())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
_, err = bw.GetSecret(ctx, tok, "no-such-secret")
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
wantErr: bw.ErrSecretNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing secret by id",
|
||||||
|
call: func(ctx context.Context, f *fixture) error {
|
||||||
|
tok, err := bw.Authenticate(ctx, f.creds())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
_, err = bw.GetSecret(ctx, tok, "00000000-0000-4000-8000-00000000dead")
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
wantErr: bw.ErrSecretNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed secrets list",
|
||||||
|
mutate: func(s *fakesm.Server, f *fixture) { s.MalformedListBody = true },
|
||||||
|
wantErr: bw.ErrMalformedResponse,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tampered value mac",
|
||||||
|
mutate: func(s *fakesm.Server, f *fixture) { s.TamperSecretMAC = true },
|
||||||
|
wantErr: bw.ErrDecrypt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "api bearer rejected",
|
||||||
|
mutate: func(s *fakesm.Server, f *fixture) { s.RejectBearer = true },
|
||||||
|
wantErr: bw.ErrAuthFailed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
if tc.mutate != nil {
|
||||||
|
tc.mutate(f.srv, f)
|
||||||
|
}
|
||||||
|
call := tc.call
|
||||||
|
if call == nil {
|
||||||
|
call = func(ctx context.Context, f *fixture) error {
|
||||||
|
tok, err := bw.Authenticate(ctx, f.creds())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
_, err = bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := call(context.Background(), f)
|
||||||
|
f.forbid(t, err)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got none")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tc.wantErr) {
|
||||||
|
t.Fatalf("error class: got %v, want %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDropEncryptedPayloadFailsClosed(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
f.srv.DropEncryptedPayload = true
|
||||||
|
tok, err := bw.Authenticate(context.Background(), f.creds())
|
||||||
|
f.forbid(t, err)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate should succeed without payload: %v", err)
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
_, err = bw.GetSecret(context.Background(), tok, f.srv.Secrets[0].Name)
|
||||||
|
f.forbid(t, err)
|
||||||
|
if !errors.Is(err, bw.ErrDecrypt) {
|
||||||
|
t.Fatalf("expected decrypt failure without org key, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitCredentialsWithoutKeyCannotDecrypt(t *testing.T) {
|
||||||
|
// Split-form credentials on an encrypting server: login works (the
|
||||||
|
// token endpoint itself needs no local key) but the encrypted payload
|
||||||
|
// cannot be unwrapped, so login must fail loudly rather than leak
|
||||||
|
// ciphertext downstream.
|
||||||
|
f := newFixture(t)
|
||||||
|
_, err := bw.Authenticate(context.Background(), bw.Credentials{
|
||||||
|
BaseURL: f.srv.BaseURL(),
|
||||||
|
ClientID: f.srv.ClientID,
|
||||||
|
ClientSecret: f.srv.ClientSecret,
|
||||||
|
})
|
||||||
|
f.forbid(t, err)
|
||||||
|
if !errors.Is(err, bw.ErrDecrypt) {
|
||||||
|
t.Fatalf("expected decrypt error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaintextServerMode(t *testing.T) {
|
||||||
|
// A server that returns unencrypted payloads (plaintext gateway or
|
||||||
|
// minimal test double) works with split credentials and with access
|
||||||
|
// tokens alike; nothing encrypted ever reaches the caller.
|
||||||
|
f := newFixture(t)
|
||||||
|
f.srv.OrgKey = nil
|
||||||
|
ctx := context.Background()
|
||||||
|
tok, err := bw.Authenticate(ctx, bw.Credentials{
|
||||||
|
BaseURL: f.srv.BaseURL(),
|
||||||
|
ClientID: f.srv.ClientID,
|
||||||
|
ClientSecret: f.srv.ClientSecret,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate: %v", err)
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
got, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
|
||||||
|
f.forbid(t, err)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got != f.srv.Secrets[0].Value {
|
||||||
|
t.Fatal("plaintext get: wrong value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshBeforeExpiry(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
f.srv.TokenTTL = 2 * time.Second // inside the 30s refresh skew
|
||||||
|
ctx := context.Background()
|
||||||
|
tok, err := bw.Authenticate(ctx, f.creds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate: %v", err)
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
if _, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name); err != nil {
|
||||||
|
t.Fatalf("get after near-expiry refresh: %v", err)
|
||||||
|
}
|
||||||
|
if f.srv.RefreshCalls == 0 {
|
||||||
|
t.Fatal("no refresh happened before expiry")
|
||||||
|
}
|
||||||
|
if _, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name); err != nil {
|
||||||
|
t.Fatalf("second get: %v", err)
|
||||||
|
}
|
||||||
|
if tok.AccessToken == "" {
|
||||||
|
t.Fatal("access token lost after refresh")
|
||||||
|
}
|
||||||
|
f.forbid(t, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredWithoutRefreshToken(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tok, err := bw.Authenticate(ctx, f.creds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate: %v", err)
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
tok.RefreshToken = "" // simulate a server that issued none
|
||||||
|
tok.ExpiresAt = time.Now().Add(-time.Minute)
|
||||||
|
_, err = bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
|
||||||
|
f.forbid(t, err)
|
||||||
|
if !errors.Is(err, bw.ErrTokenExpired) {
|
||||||
|
t.Fatalf("expected token expired, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshFailureSurfaces(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
f.srv.TokenTTL = 2 * time.Second
|
||||||
|
ctx := context.Background()
|
||||||
|
tok, err := bw.Authenticate(ctx, f.creds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate: %v", err)
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
f.srv.RejectRefresh = true
|
||||||
|
_, err = bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
|
||||||
|
f.forbid(t, err)
|
||||||
|
if !errors.Is(err, bw.ErrAuthFailed) {
|
||||||
|
t.Fatalf("expected auth failure from rejected refresh, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOmittedValueInListFallsBackToByID(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
f.srv.OmitValueInList = true
|
||||||
|
ctx := context.Background()
|
||||||
|
tok, err := bw.Authenticate(ctx, f.creds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate: %v", err)
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
got, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got != f.srv.Secrets[0].Value {
|
||||||
|
t.Fatal("fallback get: wrong value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenStringIsLogSafe(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tok, err := bw.Authenticate(context.Background(), f.creds())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate: %v", err)
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
s := tok.String()
|
||||||
|
for _, forbidden := range []string{tok.AccessToken, tok.RefreshToken, f.srv.ClientSecret} {
|
||||||
|
if forbidden != "" && strings.Contains(s, forbidden) {
|
||||||
|
t.Fatalf("Token.String leaks material: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "expires") {
|
||||||
|
t.Fatalf("Token.String lacks expiry: %s", s)
|
||||||
|
}
|
||||||
|
var nilTok *bw.Token
|
||||||
|
if _ = nilTok; nilTok.String() == "" {
|
||||||
|
t.Fatal("nil token String")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidCredentialsInput(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
creds bw.Credentials
|
||||||
|
}{
|
||||||
|
{"empty", bw.Credentials{}},
|
||||||
|
{"no base url", bw.Credentials{AccessToken: f.cred}},
|
||||||
|
{"garbage token", bw.Credentials{BaseURL: f.srv.BaseURL(), AccessToken: "not-a-token"}},
|
||||||
|
{"half split", bw.Credentials{BaseURL: f.srv.BaseURL(), ClientID: "x"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := bw.Authenticate(ctx, tc.creds)
|
||||||
|
if !errors.Is(err, bw.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("expected invalid credentials, got %v", err)
|
||||||
|
}
|
||||||
|
f.forbid(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleGetSecret() {
|
||||||
|
// Wired exactly as keyproxy's bitwarden backend will call it; the
|
||||||
|
// server URL here is a stand-in (tests bind the fake server).
|
||||||
|
creds := bw.Credentials{BaseURL: "https://vault.example.com", AccessToken: "0.<uuid>.<secret>:<key>"}
|
||||||
|
tok, err := bw.Authenticate(context.Background(), creds)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("auth:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tok.Zero()
|
||||||
|
value, err := bw.GetSecret(context.Background(), tok, "redmine-api-key")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("get:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(len(value), "chars")
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
// Package fakesm is an in-process fake Bitwarden Secrets Manager for
|
||||||
|
// tests. It speaks the real wire protocol — OAuth client_credentials at
|
||||||
|
// /identity/connect/token (including the encrypted_payload organization
|
||||||
|
// key handoff), bearer-authenticated reads under /api — and encrypts
|
||||||
|
// secret names and values as type-2 EncStrings under its own organization
|
||||||
|
// key, so the client's full decrypt chain is exercised. It is imported
|
||||||
|
// ONLY by tests and the smoke script; nothing in the library depends on
|
||||||
|
// it, and it never touches a real vault.
|
||||||
|
package fakesm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hkdf"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
bw "git.knownelement.com/ukrrs/mopac-bitwarden-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Secret is one fake secret: plaintext on the server side; the wire form
|
||||||
|
// is encrypted with the server's organization key.
|
||||||
|
type Secret struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Value string
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project is one fake project.
|
||||||
|
type Project struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the fake Secrets Manager. Behavior knobs are failure
|
||||||
|
// injectors for table-driven tests; zero values mean "behave correctly".
|
||||||
|
type Server struct {
|
||||||
|
// Credential the server accepts (the parts of the machine access
|
||||||
|
// token). NewMinted generates a consistent set and the matching
|
||||||
|
// credential string.
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
TokenKey []byte // 16 bytes, the ":key" part of the credential
|
||||||
|
OrgKey []byte // 64 bytes; nil = plaintext mode
|
||||||
|
OrgID string
|
||||||
|
TokenTTL time.Duration
|
||||||
|
|
||||||
|
Secrets []Secret
|
||||||
|
Projects []Project
|
||||||
|
|
||||||
|
// Failure injectors.
|
||||||
|
RejectAuth bool // token endpoint: 400 invalid_grant
|
||||||
|
AuthStatusOverride int // token endpoint: raw status (e.g. 500)
|
||||||
|
MalformedTokenBody bool // token endpoint: 200 with non-JSON body
|
||||||
|
MalformedListBody bool // secret list: 200 with non-JSON body
|
||||||
|
DropEncryptedPayload bool // successful login without encrypted_payload
|
||||||
|
TamperSecretMAC bool // corrupt the value MAC in responses
|
||||||
|
OmitValueInList bool // list omits value (forces by-id fetch)
|
||||||
|
RejectRefresh bool // refresh grant: 400 invalid_grant
|
||||||
|
RejectBearer bool // API endpoints: 401 for every token
|
||||||
|
|
||||||
|
// Counters for assertions.
|
||||||
|
TokenCalls int
|
||||||
|
RefreshCalls int
|
||||||
|
APICalls int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
issued map[string]time.Time // access token -> expiry
|
||||||
|
lastAccess string
|
||||||
|
lastRefresh string
|
||||||
|
srv *httptest.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer builds a server with a fixed, deterministic credential set
|
||||||
|
// (matching the published SDK sample format) and a random organization
|
||||||
|
// key, one project and two secrets. The matching machine credential
|
||||||
|
// string is returned for client configuration.
|
||||||
|
func NewServer() (*Server, string) {
|
||||||
|
orgKey := make([]byte, 64)
|
||||||
|
if _, err := rand.Read(orgKey); err != nil {
|
||||||
|
panic("fakesm: entropy: " + err.Error())
|
||||||
|
}
|
||||||
|
tokenKey := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(tokenKey); err != nil {
|
||||||
|
panic("fakesm: entropy: " + err.Error())
|
||||||
|
}
|
||||||
|
secretTail := make([]byte, 12)
|
||||||
|
_, _ = rand.Read(secretTail)
|
||||||
|
clientSecret := "C2IgxjjLF7qSshsbwe8JGcbM075YXw"
|
||||||
|
s := &Server{
|
||||||
|
ClientID: "ec2c1d46-6a4b-4751-a310-af9601317f2d",
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
TokenKey: tokenKey,
|
||||||
|
OrgKey: orgKey,
|
||||||
|
OrgID: "3fb1c0de-0000-4000-8000-000000000000",
|
||||||
|
TokenTTL: time.Hour,
|
||||||
|
Projects: []Project{
|
||||||
|
{ID: "ac1d0000-0000-4000-8000-000000000001", Name: "harness"},
|
||||||
|
},
|
||||||
|
Secrets: []Secret{
|
||||||
|
{ID: "5ec1e700-0000-4000-8000-00000000000a", Name: "redmine-api-key", Value: "rm-live-" + hex.EncodeToString(secretTail)},
|
||||||
|
{ID: "5ec1e700-0000-4000-8000-00000000000b", Name: "litellm-key", Value: "lm-live-" + hex.EncodeToString(secretTail)},
|
||||||
|
},
|
||||||
|
issued: map[string]time.Time{},
|
||||||
|
}
|
||||||
|
cred := fmt.Sprintf("0.%s.%s:%s", s.ClientID, clientSecret, base64.StdEncoding.EncodeToString(tokenKey))
|
||||||
|
return s, cred
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start boots the HTTP server; BaseURL is the server root for clients.
|
||||||
|
func (s *Server) Start() *Server {
|
||||||
|
s.srv = httptest.NewServer(http.HandlerFunc(s.handle))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe runs the fake on a plain listener (used by the smoke
|
||||||
|
// script, which boots it inside a container). It blocks until the process
|
||||||
|
// is killed.
|
||||||
|
func (s *Server) ListenAndServe(addr string) error {
|
||||||
|
if s.issued == nil {
|
||||||
|
s.issued = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
return http.ListenAndServe(addr, http.HandlerFunc(s.handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts the server down.
|
||||||
|
func (s *Server) Close() {
|
||||||
|
if s.srv != nil {
|
||||||
|
s.srv.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseURL returns the server root ("" before Start).
|
||||||
|
func (s *Server) BaseURL() string {
|
||||||
|
if s.srv == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.srv.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastAccessToken returns the most recently minted access token (for
|
||||||
|
// redaction assertions: this exact string must never appear in output).
|
||||||
|
func (s *Server) LastAccessToken() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.lastAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastRefreshToken returns the most recently issued refresh token.
|
||||||
|
func (s *Server) LastRefreshToken() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.lastRefresh
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/identity/connect/token":
|
||||||
|
s.handleToken(w, r)
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/"):
|
||||||
|
s.handleAPI(w, r)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"message": "Not found."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
grant := r.PostFormValue("grant_type")
|
||||||
|
if grant == "refresh_token" {
|
||||||
|
s.RefreshCalls++
|
||||||
|
} else {
|
||||||
|
s.TokenCalls++
|
||||||
|
}
|
||||||
|
if s.AuthStatusOverride != 0 {
|
||||||
|
writeJSON(w, s.AuthStatusOverride, map[string]string{"message": "boom"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.MalformedTokenBody {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `{this-is-not-json`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.RejectAuth {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant", "error_description": "invalid_username_or_password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch grant {
|
||||||
|
case "client_credentials":
|
||||||
|
if r.PostFormValue("client_id") != s.ClientID || r.PostFormValue("client_secret") != s.ClientSecret || r.PostFormValue("scope") != "api.secrets" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant", "error_description": "invalid_username_or_password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := s.mintAccessToken()
|
||||||
|
if !s.DropEncryptedPayload && s.OrgKey != nil {
|
||||||
|
body["encrypted_payload"] = s.encryptPayload()
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, body)
|
||||||
|
case "refresh_token":
|
||||||
|
if s.RejectRefresh || r.PostFormValue("refresh_token") == "" || r.PostFormValue("client_id") != s.ClientID {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant", "error_description": "invalid_refresh_token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.mintAccessToken())
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported_grant_type"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mintAccessToken issues and records a fresh access/refresh pair and
|
||||||
|
// returns the response body (without encrypted_payload; the caller adds
|
||||||
|
// it for client_credentials logins only, mirroring the real server).
|
||||||
|
func (s *Server) mintAccessToken() map[string]any {
|
||||||
|
claims := map[string]any{
|
||||||
|
"sub": s.ClientID,
|
||||||
|
"organization": s.OrgID,
|
||||||
|
"scope": []string{"api.secrets"},
|
||||||
|
"exp": time.Now().Add(s.TokenTTL).Unix(),
|
||||||
|
"nbf": time.Now().Add(-time.Minute).Unix(),
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(claims)
|
||||||
|
access := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) +
|
||||||
|
"." + base64.RawURLEncoding.EncodeToString(payload) +
|
||||||
|
"." + base64.RawURLEncoding.EncodeToString([]byte("fake-signature"))
|
||||||
|
refreshBytes := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(refreshBytes)
|
||||||
|
refresh := "rt-" + hex.EncodeToString(refreshBytes)
|
||||||
|
s.issued[access] = time.Now().Add(s.TokenTTL)
|
||||||
|
s.lastAccess = access
|
||||||
|
s.lastRefresh = refresh
|
||||||
|
return map[string]any{
|
||||||
|
"access_token": access,
|
||||||
|
"expires_in": int64(s.TokenTTL.Seconds()),
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"scope": "api.secrets",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptPayload seals the organization key for this credential exactly
|
||||||
|
// as the real server does: HKDF-SHA256 over the credential's 16-byte key
|
||||||
|
// (salt "bitwarden-accesstoken", info "sm-access-token"), then a type-2
|
||||||
|
// EncString of {"encryptionKey": "<b64 org key>"}.
|
||||||
|
func (s *Server) encryptPayload() string {
|
||||||
|
okm, err := hkdf.Key(sha256.New, s.TokenKey, []byte("bitwarden-accesstoken"), "sm-access-token", 64)
|
||||||
|
if err != nil {
|
||||||
|
panic("fakesm: hkdf: " + err.Error())
|
||||||
|
}
|
||||||
|
key, err := bw.NewSymmetricKey(okm)
|
||||||
|
if err != nil {
|
||||||
|
panic("fakesm: key: " + err.Error())
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]string{"encryptionKey": base64.StdEncoding.EncodeToString(s.OrgKey)})
|
||||||
|
enc, err := bw.Encrypt(key, payload)
|
||||||
|
if err != nil {
|
||||||
|
panic("fakesm: encrypt: " + err.Error())
|
||||||
|
}
|
||||||
|
return enc.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.APICalls++
|
||||||
|
if s.RejectBearer || !s.bearerValidLocked(r) {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/")
|
||||||
|
switch {
|
||||||
|
case path == "accounts/"+s.ClientID+"/secrets":
|
||||||
|
if s.MalformedListBody {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `{"data": [broken`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := make([]map[string]any, 0, len(s.Secrets))
|
||||||
|
for _, sec := range s.Secrets {
|
||||||
|
item := map[string]any{
|
||||||
|
"object": "secret",
|
||||||
|
"id": sec.ID,
|
||||||
|
"organizationId": s.OrgID,
|
||||||
|
"key": s.seal(sec.Name),
|
||||||
|
"note": s.seal(sec.Note),
|
||||||
|
"creationDate": "2026-08-28T00:00:00Z",
|
||||||
|
"revisionDate": "2026-08-28T00:00:00Z",
|
||||||
|
"read": true,
|
||||||
|
"write": false,
|
||||||
|
}
|
||||||
|
if !s.OmitValueInList {
|
||||||
|
item["value"] = s.sealValue(sec.Value)
|
||||||
|
}
|
||||||
|
list = append(list, item)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": list, "continuationToken": nil})
|
||||||
|
case strings.HasPrefix(path, "secrets/"):
|
||||||
|
id := strings.TrimPrefix(path, "secrets/")
|
||||||
|
for _, sec := range s.Secrets {
|
||||||
|
if sec.ID == id {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"object": "secretDetails",
|
||||||
|
"id": sec.ID,
|
||||||
|
"organizationId": s.OrgID,
|
||||||
|
"key": s.seal(sec.Name),
|
||||||
|
"value": s.sealValue(sec.Value),
|
||||||
|
"note": s.seal(sec.Note),
|
||||||
|
"creationDate": "2026-08-28T00:00:00Z",
|
||||||
|
"revisionDate": "2026-08-28T00:00:00Z",
|
||||||
|
"read": true,
|
||||||
|
"write": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"message": "Not found."})
|
||||||
|
case path == "accounts/"+s.ClientID+"/projects":
|
||||||
|
list := make([]map[string]any, 0, len(s.Projects))
|
||||||
|
for _, p := range s.Projects {
|
||||||
|
list = append(list, map[string]any{
|
||||||
|
"object": "project",
|
||||||
|
"id": p.ID,
|
||||||
|
"organizationId": s.OrgID,
|
||||||
|
"name": s.seal(p.Name),
|
||||||
|
"creationDate": "2026-08-28T00:00:00Z",
|
||||||
|
"revisionDate": "2026-08-28T00:00:00Z",
|
||||||
|
"read": true,
|
||||||
|
"write": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": list, "continuationToken": nil})
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"message": "Not found."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) bearerValidLocked(r *http.Request) bool {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if !strings.HasPrefix(auth, prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
exp, ok := s.issued[strings.TrimPrefix(auth, prefix)]
|
||||||
|
return ok && exp.After(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// seal encrypts v with the organization key (nil org key = plaintext
|
||||||
|
// server mode).
|
||||||
|
func (s *Server) seal(v string) string {
|
||||||
|
if s.OrgKey == nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
key, err := bw.NewSymmetricKey(s.OrgKey)
|
||||||
|
if err != nil {
|
||||||
|
panic("fakesm: org key: " + err.Error())
|
||||||
|
}
|
||||||
|
enc, err := bw.Encrypt(key, []byte(v))
|
||||||
|
if err != nil {
|
||||||
|
panic("fakesm: encrypt: " + err.Error())
|
||||||
|
}
|
||||||
|
return enc.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sealValue additionally supports MAC tampering for integrity tests.
|
||||||
|
func (s *Server) sealValue(v string) string {
|
||||||
|
out := s.seal(v)
|
||||||
|
if !s.TamperSecretMAC || s.OrgKey == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
parts := strings.Split(out, ".")
|
||||||
|
ct, err := base64.StdEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
ct[0] ^= 0xFF
|
||||||
|
parts[2] = base64.StdEncoding.EncodeToString(ct)
|
||||||
|
return strings.Join(parts, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
@@ -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