// Package mciasgoclient provides a thread-safe Go client for the MCIAS REST API. // // Security: bearer tokens are stored under a sync.RWMutex and are never written // to logs or included in error messages anywhere in this package. package mciasgoclient import ( "bytes" "crypto/tls" "crypto/x509" "encoding/json" "fmt" "io" "net/http" "os" "strings" "sync" ) // --------------------------------------------------------------------------- // Error types // --------------------------------------------------------------------------- // MciasError is the base error type for all MCIAS client errors. type MciasError struct { StatusCode int Message string } func (e *MciasError) Error() string { return fmt.Sprintf("mciasgoclient: HTTP %d: %s", e.StatusCode, e.Message) } // MciasAuthError is returned for 401 Unauthorized responses. type MciasAuthError struct{ MciasError } // MciasForbiddenError is returned for 403 Forbidden responses. type MciasForbiddenError struct{ MciasError } // MciasNotFoundError is returned for 404 Not Found responses. type MciasNotFoundError struct{ MciasError } // MciasInputError is returned for 400 Bad Request responses. type MciasInputError struct{ MciasError } // MciasConflictError is returned for 409 Conflict responses. type MciasConflictError struct{ MciasError } // MciasServerError is returned for 5xx responses. type MciasServerError struct{ MciasError } // --------------------------------------------------------------------------- // Data types // --------------------------------------------------------------------------- // Account represents a user or service account. type Account struct { ID string `json:"id"` Username string `json:"username"` AccountType string `json:"account_type"` Status string `json:"status"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` TOTPEnabled bool `json:"totp_enabled"` } // PublicKey represents the server's Ed25519 public key in JWK format. type PublicKey struct { Kty string `json:"kty"` Crv string `json:"crv"` X string `json:"x"` Use string `json:"use,omitempty"` Alg string `json:"alg,omitempty"` } // TokenClaims is returned by ValidateToken. type TokenClaims struct { Valid bool `json:"valid"` Sub string `json:"sub,omitempty"` Roles []string `json:"roles,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` } // PGCreds holds Postgres connection credentials. type PGCreds struct { Host string `json:"host"` Port int `json:"port"` Database string `json:"database"` Username string `json:"username"` Password string `json:"password"` } // --------------------------------------------------------------------------- // Options and Client struct // --------------------------------------------------------------------------- // Options configures the MCIAS client. type Options struct { // CACertPath is an optional path to a PEM-encoded CA certificate for TLS // verification of self-signed or private-CA certificates. CACertPath string // Token is an optional pre-existing bearer token. Token string } // Client is a thread-safe MCIAS REST API client. // Security: the bearer token is guarded by a sync.RWMutex; it is never // written to logs or included in error messages in this library. type Client struct { baseURL string http *http.Client mu sync.RWMutex token string } // --------------------------------------------------------------------------- // Constructor // --------------------------------------------------------------------------- // New creates a new Client for the given serverURL. // TLS 1.2 is the minimum version enforced on all connections. // If opts.CACertPath is set, that CA certificate is added to the trust pool. func New(serverURL string, opts Options) (*Client, error) { serverURL = strings.TrimRight(serverURL, "/") // Security: never negotiate TLS < 1.2; this prevents POODLE/BEAST-class // downgrade attacks against the token-bearing transport. tlsCfg := &tls.Config{ MinVersion: tls.VersionTLS12, } if opts.CACertPath != "" { pem, err := os.ReadFile(opts.CACertPath) if err != nil { return nil, fmt.Errorf("mciasgoclient: read CA cert: %w", err) } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("mciasgoclient: no valid certs in CA file") } tlsCfg.RootCAs = pool } transport := &http.Transport{TLSClientConfig: tlsCfg} c := &Client{ baseURL: serverURL, http: &http.Client{Transport: transport}, token: opts.Token, } return c, nil } // Token returns the current bearer token (empty string if not logged in). func (c *Client) Token() string { c.mu.RLock() defer c.mu.RUnlock() return c.token } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- func (c *Client) setToken(tok string) { c.mu.Lock() defer c.mu.Unlock() c.token = tok } func (c *Client) do(method, path string, body interface{}, out interface{}) error { var reqBody io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return fmt.Errorf("mciasgoclient: marshal request: %w", err) } reqBody = bytes.NewReader(b) } req, err := http.NewRequest(method, c.baseURL+path, reqBody) if err != nil { return fmt.Errorf("mciasgoclient: build request: %w", err) } req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } // Security: token is read under the lock and added to the Authorization // header only; it is never written to any log or error message in this // library. c.mu.RLock() tok := c.token c.mu.RUnlock() if tok != "" { req.Header.Set("Authorization", "Bearer "+tok) } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("mciasgoclient: request: %w", err) } defer resp.Body.Close() respBytes, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("mciasgoclient: read response: %w", err) } if resp.StatusCode >= 400 { var errResp struct { Error string `json:"error"` } _ = json.Unmarshal(respBytes, &errResp) msg := errResp.Error if msg == "" { msg = fmt.Sprintf("HTTP %d", resp.StatusCode) } return makeError(resp.StatusCode, msg) } if out != nil && len(respBytes) > 0 { dec := json.NewDecoder(bytes.NewReader(respBytes)) dec.DisallowUnknownFields() if err := dec.Decode(out); err != nil { return fmt.Errorf("mciasgoclient: decode response: %w", err) } } return nil } func makeError(status int, msg string) error { base := MciasError{StatusCode: status, Message: msg} switch { case status == 401: return &MciasAuthError{base} case status == 403: return &MciasForbiddenError{base} case status == 404: return &MciasNotFoundError{base} case status == 400: return &MciasInputError{base} case status == 409: return &MciasConflictError{base} default: return &MciasServerError{base} } } // --------------------------------------------------------------------------- // API methods // --------------------------------------------------------------------------- // Health calls GET /v1/health. Returns nil if the server is healthy. func (c *Client) Health() error { return c.do(http.MethodGet, "/v1/health", nil, nil) } // GetPublicKey returns the server's Ed25519 public key in JWK format. func (c *Client) GetPublicKey() (*PublicKey, error) { var pk PublicKey if err := c.do(http.MethodGet, "/v1/keys/public", nil, &pk); err != nil { return nil, err } return &pk, nil } // Login authenticates with username and password. On success the token is // stored in the Client and returned along with the expiry timestamp. // totpCode may be empty for accounts without TOTP. func (c *Client) Login(username, password, totpCode string) (token, expiresAt string, err error) { req := map[string]string{"username": username, "password": password} if totpCode != "" { req["totp_code"] = totpCode } var resp struct { Token string `json:"token"` ExpiresAt string `json:"expires_at"` } if err := c.do(http.MethodPost, "/v1/auth/login", req, &resp); err != nil { return "", "", err } c.setToken(resp.Token) return resp.Token, resp.ExpiresAt, nil } // Logout revokes the current token on the server and clears it from the client. func (c *Client) Logout() error { if err := c.do(http.MethodPost, "/v1/auth/logout", nil, nil); err != nil { return err } c.setToken("") return nil } // RenewToken exchanges the current token for a fresh one. // The new token is stored in the client and returned. func (c *Client) RenewToken() (token, expiresAt string, err error) { var resp struct { Token string `json:"token"` ExpiresAt string `json:"expires_at"` } if err := c.do(http.MethodPost, "/v1/auth/renew", map[string]string{}, &resp); err != nil { return "", "", err } c.setToken(resp.Token) return resp.Token, resp.ExpiresAt, nil } // ValidateToken validates a token string against the server. // Returns claims; Valid is false (not an error) if the token is expired or // revoked. func (c *Client) ValidateToken(token string) (*TokenClaims, error) { var claims TokenClaims if err := c.do(http.MethodPost, "/v1/token/validate", map[string]string{"token": token}, &claims); err != nil { return nil, err } return &claims, nil } // CreateAccount creates a new account. accountType is "human" or "system". // password is required for human accounts. func (c *Client) CreateAccount(username, accountType, password string) (*Account, error) { req := map[string]string{ "username": username, "account_type": accountType, } if password != "" { req["password"] = password } var acct Account if err := c.do(http.MethodPost, "/v1/accounts", req, &acct); err != nil { return nil, err } return &acct, nil } // ListAccounts returns all accounts. Requires admin role. func (c *Client) ListAccounts() ([]Account, error) { var accounts []Account if err := c.do(http.MethodGet, "/v1/accounts", nil, &accounts); err != nil { return nil, err } return accounts, nil } // GetAccount returns the account with the given ID. Requires admin role. func (c *Client) GetAccount(id string) (*Account, error) { var acct Account if err := c.do(http.MethodGet, "/v1/accounts/"+id, nil, &acct); err != nil { return nil, err } return &acct, nil } // UpdateAccount updates mutable account fields. Requires admin role. // Pass an empty string for fields that should not be changed. func (c *Client) UpdateAccount(id, status string) (*Account, error) { req := map[string]string{} if status != "" { req["status"] = status } var acct Account if err := c.do(http.MethodPatch, "/v1/accounts/"+id, req, &acct); err != nil { return nil, err } return &acct, nil } // DeleteAccount soft-deletes the account with the given ID. Requires admin. func (c *Client) DeleteAccount(id string) error { return c.do(http.MethodDelete, "/v1/accounts/"+id, nil, nil) } // GetRoles returns the roles for accountID. Requires admin. func (c *Client) GetRoles(accountID string) ([]string, error) { var resp struct { Roles []string `json:"roles"` } if err := c.do(http.MethodGet, "/v1/accounts/"+accountID+"/roles", nil, &resp); err != nil { return nil, err } return resp.Roles, nil } // SetRoles replaces the role set for accountID. Requires admin. func (c *Client) SetRoles(accountID string, roles []string) error { return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/roles", map[string][]string{"roles": roles}, nil) } // IssueServiceToken issues a long-lived token for a system account. Requires admin. func (c *Client) IssueServiceToken(accountID string) (token, expiresAt string, err error) { var resp struct { Token string `json:"token"` ExpiresAt string `json:"expires_at"` } if err := c.do(http.MethodPost, "/v1/token/issue", map[string]string{"account_id": accountID}, &resp); err != nil { return "", "", err } return resp.Token, resp.ExpiresAt, nil } // RevokeToken revokes a token by JTI. Requires admin. func (c *Client) RevokeToken(jti string) error { return c.do(http.MethodDelete, "/v1/token/"+jti, nil, nil) } // GetPGCreds returns Postgres credentials for accountID. Requires admin. func (c *Client) GetPGCreds(accountID string) (*PGCreds, error) { var creds PGCreds if err := c.do(http.MethodGet, "/v1/accounts/"+accountID+"/pgcreds", nil, &creds); err != nil { return nil, err } return &creds, nil } // SetPGCreds stores Postgres credentials for accountID. Requires admin. // The password is sent over TLS and encrypted at rest server-side. func (c *Client) SetPGCreds(accountID, host string, port int, database, username, password string) error { return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/pgcreds", map[string]interface{}{ "host": host, "port": port, "database": database, "username": username, "password": password, }, nil) }