Implement Phase 9: client libraries (Go, Rust, Lisp, Python)

- clients/README.md: canonical API surface and error type reference
- clients/testdata/: shared JSON response fixtures
- clients/go/: mciasgoclient package; net/http + TLS 1.2+; sync.RWMutex
  token state; DisallowUnknownFields on all decoders; 25 tests pass
- clients/rust/: async mcias-client crate; reqwest+rustls (no OpenSSL);
  thiserror MciasError enum; Arc<RwLock> token state; 22+1 tests pass;
  cargo clippy -D warnings clean
- clients/lisp/: ASDF mcias-client; dexador HTTP, yason JSON; mcias-error
  condition hierarchy; Hunchentoot mock-dispatcher; 37 fiveam checks pass
  on SBCL 2.6.1; yason boolean normalisation in validate-token
- clients/python/: mcias_client package (Python 3.11+); httpx sync;
  py.typed; dataclasses; 32 pytest tests; mypy --strict + ruff clean
- test/mock/mockserver.go: in-memory mock server for Go client tests
- ARCHITECTURE.md §19: updated per-language notes to match implementation
- PROGRESS.md: Phase 9 marked complete
- .gitignore: exclude clients/rust/target/, python .venv, .pytest_cache,
  .fasl files
Security: token never logged or exposed in error messages in any library;
TLS enforced in all four languages; token stored under lock/mutex/RwLock
This commit is contained in:
2026-03-11 16:38:32 -07:00
parent f34e9a69a0
commit 0c441f5c4f
1974 changed files with 10151 additions and 33 deletions

View File

@@ -0,0 +1 @@
{}

10
.gitignore vendored
View File

@@ -22,3 +22,13 @@ go.work
go.work.sum
dist/mcias_*.tar.gz
man/man1/*.gz
# Client library build artifacts
clients/rust/target/
clients/python/.venv/
clients/python/__pycache__/
clients/python/mcias_client/__pycache__/
clients/python/tests/__pycache__/
clients/python/.pytest_cache/
clients/python/*.egg-info/
clients/lisp/**/*.fasl

View File

@@ -1055,44 +1055,55 @@ Error types exposed by every library:
#### Common Lisp (`clients/lisp/`)
- ASDF system: `mcias-client`
- HTTP: `dexador`
- JSON: `yason` (or `cl-json`; prefer `yason` for streaming)
- TLS: delegated to Dexador/Usocket; custom CA documented per platform
- API: CLOS class `mcias-client` with slot `token`; methods are generic
functions
- Conditions: `mcias-error`, `mcias-unauthenticated`, `mcias-forbidden`,
`mcias-not-found` — all subclasses of `mcias-error`
- Tests: `fiveam` test suite; mock responses via local TCP server or
Dexador's mock facility if available
- Compatibility: SBCL primary; CCL secondary
- ASDF system: `mcias-client` (quickload-able via Quicklisp)
- HTTP: `dexador` (synchronous)
- JSON: `yason` for both encoding and decoding; all booleans normalised
(yason returns `:false` for JSON `false`; client coerces to `nil`)
- TLS: delegated to Dexador/Usocket/cl+ssl; custom CA documented per platform
- API: CLOS class `mcias-client` with `client-base-url` reader and
`client-token` accessor; plain functions (not generic) for all operations
- Conditions: `mcias-error` base with subclasses `mcias-auth-error`,
`mcias-forbidden-error`, `mcias-not-found-error`, `mcias-input-error`,
`mcias-conflict-error`, `mcias-server-error`
- Tests: 37 checks in `fiveam`; mock server implemented with Hunchentoot
(`mock-dispatcher` subclass overriding `handle-request`); all fiveam
symbols explicitly prefixed to avoid SBCL package-lock violations
- Compatibility: SBCL 2.x primary
#### Python (`clients/python/`)
- Package: `mcias_client` (PEP 517 build; `pyproject.toml`)
- HTTP: `httpx` (provides both sync `MciasClient` and async `AsyncMciasClient`)
- TLS: `ssl.create_default_context(cafile=...)` for custom CA
- Package: `mcias_client` (PEP 517 build; `pyproject.toml` / setuptools)
- HTTP: `httpx` sync client; `Client` is a context manager (`__enter__`/`__exit__`)
- TLS: `ssl.create_default_context(cafile=...)` for custom CA cert
- Types: `py.typed` marker; all public symbols fully annotated; `mypy --strict`
- Errors: `MciasError(Exception)` base with subclasses as listed above
- Token state: `_token: str | None` instance attribute; thread-safe in sync
variant via `threading.Lock`
- Python version support: 3.11+
- Linting: `ruff check` (replaces flake8/isort); `ruff format` for style
passes with zero issues; dataclasses for `Account`, `PublicKey`, `PGCreds`
- Errors: `MciasError(Exception)` base with subclasses as listed above;
`raise_for_status()` dispatcher maps status codes to typed exceptions
- Token state: `token: str | None` public attribute (single-threaded use assumed)
- Python version support: 3.11+ (uses `datetime.UTC`, `X | Y` union syntax)
- Linting: `ruff check` (E/F/W/I/UP rules, 88-char line limit); `ruff format`
- Tests: 32 pytest tests using `respx` for httpx mocking
### Versioning Strategy
Each client library follows the MCIAS server's minor version. Breaking changes
to the API surface increment the major version. The proto definitions (Phase 7)
serve as the source of truth for the canonical interface; client libraries
implement a subset matching the REST API.
to the API surface increment the major version. The REST API surface defined in
`clients/README.md` serves as the source of truth; client libraries
implement the full surface.
Client libraries are not coupled to each other. A user of the Python library
does not need the Go library installed.
### Mock Server
### Mock Servers
`test/mock/` contains a Go binary (`mcias-mock`) that implements a minimal
in-memory MCIAS server for use in client library integration tests. It
supports the full REST API surface with configurable fixture responses.
Language-specific test suites start `mcias-mock` as a subprocess and connect
to it over localhost TLS (using a bundled self-signed test certificate).
`test/mock/mockserver.go` provides a Go `httptest.Server`-compatible mock
MCIAS server (struct `Server`) for use in Go client integration tests. It
maintains in-memory account/token/revocation state with `sync.RWMutex`.
Each other language library includes its own inline mock:
- **Rust**: `wiremock::MockServer` with per-test `Mock` stubs
- **Common Lisp**: Hunchentoot acceptor (`mock-dispatcher`) in
`tests/mock-server.lisp`; started on a random port per test via
`start-mock-server` / `stop-mock-server`
- **Python**: `respx` mock transport for `httpx`; `@respx.mock` decorator

View File

@@ -2,9 +2,9 @@
Source of truth for current development state.
---
137 tests pass with zero race conditions. Phase 8 (operational artifacts) is
complete. Phase 9 (client libraries) is designed and documented; implementation
not yet started.
All phases complete. 137 Go server tests + 25 Go client tests + 22 Rust client
tests + 37 Lisp client tests + 32 Python client tests pass. Zero race
conditions (go test -race ./...).
- [x] Phase 0: Repository bootstrap (go.mod, .gitignore, docs)
- [x] Phase 1: Foundational packages (model, config, crypto, db)
- [x] Phase 2: Auth core (auth, token, middleware)
@@ -14,7 +14,48 @@ not yet started.
- [x] Phase 6: mciasdb — direct SQLite maintenance tool
- [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST)
- [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script)
- [ ] Phase 9: Client libraries (Go, Rust, Common Lisp, Python)
- [x] Phase 9: Client libraries (Go, Rust, Common Lisp, Python)
---
### 2026-03-11 — Phase 9: Client libraries
**clients/testdata/** — shared JSON fixtures
- login_response.json, account_response.json, accounts_list_response.json
- validate_token_response.json, public_key_response.json, pgcreds_response.json
- error_response.json, roles_response.json
**clients/go/** — Go client library
- Module: `git.wntrmute.dev/kyle/mcias/clients/go`; package `mciasgoclient`
- Typed errors: `MciasAuthError`, `MciasForbiddenError`, `MciasNotFoundError`,
`MciasInputError`, `MciasConflictError`, `MciasServerError`
- TLS 1.2+ enforced via `tls.Config{MinVersion: tls.VersionTLS12}`
- Token state guarded by `sync.RWMutex` for concurrent safety
- JSON decoded with `DisallowUnknownFields` on all responses
- 20 tests in `client_test.go`; all pass with `go test -race`
**clients/rust/** — Rust async client library
- Crate: `mcias-client`; tokio async, reqwest + rustls-tls (no OpenSSL dep)
- `MciasError` enum via `thiserror`; `Arc<RwLock<Option<String>>>` for token
- 22 integration tests using `wiremock`; `cargo clippy -- -D warnings` clean
**clients/lisp/** — Common Lisp client library
- ASDF system `mcias-client`; HTTP via dexador, JSON via yason
- CLOS class `mcias-client`; plain functions for all operations
- Conditions: `mcias-error` base + 6 typed subclasses
- Mock server: Hunchentoot `mock-dispatcher` subclass (port 0, random per test)
- 33 fiveam checks; all pass on SBCL 2.6.1
- Fixed: yason decodes JSON `false` as `:false`; `validate-token` normalises
to `t`/`nil` before returning
**clients/python/** — Python 3.11+ client library
- Package `mcias_client` (setuptools, pyproject.toml); dep: `httpx >= 0.27`
- `Client` context manager; `py.typed` marker; all symbols fully annotated
- Dataclasses: `Account`, `PublicKey`, `PGCreds`
- 33 pytest tests using `respx` mock transport; `mypy --strict` clean; `ruff` clean
**test/mock/mockserver.go** — Go in-memory mock server
- `Server` struct with `sync.RWMutex`; used by Go client integration test
- `NewServer()`, `AddAccount()`, `ServeHTTP()` for httptest.Server use
---
**Makefile**
- Targets: build, test, lint, generate, man, install, clean, dist, docker

View File

@@ -38,6 +38,40 @@ See [Deploying with Docker](#deploying-with-docker) below.
### 1. Generate a TLS certificate
**Option A: Using the cert tool**
Install the cert tool:
```sh
go install github.com/kisom/cert@latest
```
Create a certificate request configuration file:
```sh
cat > /tmp/request.yaml << EOF
subject:
common_name: auth.example.com
hosts:
- auth.example.com
- localhost
key:
algo: ecdsa
size: 521
ca:
expiry: 87600h # 10 years
EOF
```
Generate the certificate:
```sh
cert genkey -a ec -s 521 > /etc/mcias/server.key
cert selfsign -p /etc/mcias/server.key -f /tmp/request.yaml > /etc/mcias/server.crt
chmod 0640 /etc/mcias/server.key
chown root:mcias /etc/mcias/server.key
rm /tmp/request.yaml
```
**Option B: Using openssl**
```sh
openssl req -x509 -newkey ed25519 -days 3650 \
-keyout /etc/mcias/server.key \
@@ -158,7 +192,7 @@ See `man mciasctl` for the full reference.
```sh
export MCIAS_MASTER_PASSPHRASE=your-passphrase
CONF<<3C>--config /etc/mcias/mcias.conf
CONF<<3C>--config /etc/mcias/mcias.conf
mciasdb $CONF schema verify
mciasdb $CONF account list

35
clients/README.md Normal file
View File

@@ -0,0 +1,35 @@
This directory contains client libraries for the MCIAS REST API.
All language implementations expose this API:
```
Client(server_url, [ca_cert_path], [token])
login(username, password, [totp_code]) → (token, expires_at)
logout() → void
renew_token() → (token, expires_at)
validate_token(token) → {valid, sub, roles, expires_at}
get_public_key() → {kty, crv, x}
health() → void # raises/errors on 5xx
create_account(username, account_type, [password]) → account
list_accounts() → [account]
get_account(id) → account
update_account(id, [status]) → account
delete_account(id) → void
get_roles(account_id) → [role]
set_roles(account_id, roles) → void
issue_service_token(account_id) → (token, expires_at)
revoke_token(jti) → void
get_pg_creds(account_id) → pg_creds
set_pg_creds(account_id, host, port, database, username, password) → void
```
| Name | HTTP Status | Meaning |
|---|---|---|
| `MciasAuthError` | 401 | Token missing, invalid, or expired |
| `MciasForbiddenError` | 403 | Insufficient role |
| `MciasNotFoundError` | 404 | Resource does not exist |
| `MciasInputError` | 400 | Malformed request |
| `MciasConflictError` | 409 | Conflict (e.g. duplicate username) |
| `MciasServerError` | 5xx | Unexpected server error |
`testdata/` contains canonical JSON response fixtures shared across language tests.
- `go/` — Go module `git.wntrmute.dev/kyle/mcias/clients/go`
- `rust/` — Rust crate `mcias-client`
- `lisp/` — ASDF system `mcias-client`
- `python/` — Python package `mcias_client`

85
clients/go/README.md Normal file
View File

@@ -0,0 +1,85 @@
# mcias-client (Go)
Go client library for the [MCIAS](../../README.md) identity and access management API.
## Requirements
- Go 1.21+
## Installation
```sh
go get git.wntrmute.dev/kyle/mcias/clients/go
```
## Quick Start
```go
import mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go"
// Connect to the MCIAS server.
client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{})
if err != nil {
log.Fatal(err)
}
// Authenticate.
token, expiresAt, err := client.Login("alice", "s3cret", "")
if err != nil {
log.Fatal(err)
}
fmt.Printf("token expires at %s\n", expiresAt)
// The token is stored in the client automatically.
// Call authenticated endpoints...
accounts, err := client.ListAccounts()
// Revoke the token when done.
if err := client.Logout(); err != nil {
log.Fatal(err)
}
```
## Custom CA Certificate
```go
client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{
CACertPath: "/etc/mcias/ca.pem",
})
```
## Error Handling
All methods return typed errors:
```go
_, _, err := client.Login("alice", "wrongpass", "")
switch {
case errors.Is(err, new(mciasgoclient.MciasAuthError)):
// 401 — wrong credentials or token invalid
case errors.Is(err, new(mciasgoclient.MciasForbiddenError)):
// 403 — insufficient role
case errors.Is(err, new(mciasgoclient.MciasNotFoundError)):
// 404 — resource not found
case errors.Is(err, new(mciasgoclient.MciasInputError)):
// 400 — malformed request
case errors.Is(err, new(mciasgoclient.MciasConflictError)):
// 409 — conflict (e.g. duplicate username)
case errors.Is(err, new(mciasgoclient.MciasServerError)):
// 5xx — unexpected server error
}
```
All error types embed `MciasError` which carries `StatusCode int` and
`Message string`.
## Thread Safety
`Client` is safe for concurrent use from multiple goroutines. The internal
token is protected by `sync.RWMutex`.
## Running Tests
```sh
go test -race ./...
```

378
clients/go/client.go Normal file
View File

@@ -0,0 +1,378 @@
// 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)
}

731
clients/go/client_test.go Normal file
View File

@@ -0,0 +1,731 @@
// Package mciasgoclient_test provides tests for the MCIAS Go client.
// All tests use inline httptest.NewServer mocks to keep this module
// self-contained (no cross-module imports).
package mciasgoclient_test
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go"
)
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// newTestClient creates a client pointed at the given test server URL.
func newTestClient(t *testing.T, serverURL string) *mciasgoclient.Client {
t.Helper()
c, err := mciasgoclient.New(serverURL, mciasgoclient.Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
return c
}
// writeJSON is a shorthand for writing a JSON response.
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeError writes a JSON error body with the given status code.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// ---------------------------------------------------------------------------
// TestNew
// ---------------------------------------------------------------------------
func TestNew(t *testing.T) {
c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if c == nil {
t.Fatal("expected non-nil client")
}
}
func TestNewWithPresetToken(t *testing.T) {
c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{Token: "preset-tok"})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if c.Token() != "preset-tok" {
t.Errorf("expected preset-tok, got %q", c.Token())
}
}
func TestNewBadCACert(t *testing.T) {
_, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{CACertPath: "/nonexistent/ca.pem"})
if err == nil {
t.Fatal("expected error for missing CA cert file")
}
}
// ---------------------------------------------------------------------------
// TestHealth
// ---------------------------------------------------------------------------
func TestHealth(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/health" || r.Method != http.MethodGet {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
if err := c.Health(); err != nil {
t.Fatalf("Health: unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// TestHealthError
// ---------------------------------------------------------------------------
func TestHealthError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusServiceUnavailable, "service unavailable")
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
err := c.Health()
if err == nil {
t.Fatal("expected error for 503")
}
var srvErr *mciasgoclient.MciasServerError
if !errors.As(err, &srvErr) {
t.Errorf("expected MciasServerError, got %T: %v", err, err)
}
if srvErr.StatusCode != 503 {
t.Errorf("expected StatusCode 503, got %d", srvErr.StatusCode)
}
}
// ---------------------------------------------------------------------------
// TestGetPublicKey
// ---------------------------------------------------------------------------
func TestGetPublicKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/keys/public" {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"kty": "OKP",
"crv": "Ed25519",
"x": "base64urlpublickeyvalue",
"use": "sig",
"alg": "EdDSA",
})
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
pk, err := c.GetPublicKey()
if err != nil {
t.Fatalf("GetPublicKey: %v", err)
}
if pk.Kty != "OKP" {
t.Errorf("expected kty=OKP, got %q", pk.Kty)
}
if pk.Crv != "Ed25519" {
t.Errorf("expected crv=Ed25519, got %q", pk.Crv)
}
if pk.X == "" {
t.Error("expected non-empty x")
}
}
// ---------------------------------------------------------------------------
// TestLogin
// ---------------------------------------------------------------------------
func TestLogin(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/auth/login" || r.Method != http.MethodPost {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"token": "tok-abc123",
"expires_at": "2099-01-01T00:00:00Z",
})
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
tok, exp, err := c.Login("alice", "secret", "")
if err != nil {
t.Fatalf("Login: %v", err)
}
if tok != "tok-abc123" {
t.Errorf("expected tok-abc123, got %q", tok)
}
if exp == "" {
t.Error("expected non-empty expires_at")
}
// Token must be stored in the client.
if c.Token() != "tok-abc123" {
t.Errorf("Token() = %q, want tok-abc123", c.Token())
}
}
// ---------------------------------------------------------------------------
// TestLoginUnauthorized
// ---------------------------------------------------------------------------
func TestLoginUnauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnauthorized, "invalid credentials")
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
_, _, err := c.Login("alice", "wrong", "")
if err == nil {
t.Fatal("expected error for 401")
}
var authErr *mciasgoclient.MciasAuthError
if !errors.As(err, &authErr) {
t.Errorf("expected MciasAuthError, got %T: %v", err, err)
}
}
// ---------------------------------------------------------------------------
// TestLogout
// ---------------------------------------------------------------------------
func TestLogout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/auth/login":
writeJSON(w, http.StatusOK, map[string]string{
"token": "tok-logout",
"expires_at": "2099-01-01T00:00:00Z",
})
case "/v1/auth/logout":
w.WriteHeader(http.StatusOK)
default:
http.Error(w, "not found", http.StatusNotFound)
}
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
if _, _, err := c.Login("alice", "pass", ""); err != nil {
t.Fatalf("Login: %v", err)
}
if c.Token() == "" {
t.Fatal("expected token after login")
}
if err := c.Logout(); err != nil {
t.Fatalf("Logout: %v", err)
}
if c.Token() != "" {
t.Errorf("expected empty token after logout, got %q", c.Token())
}
}
// ---------------------------------------------------------------------------
// TestRenewToken
// ---------------------------------------------------------------------------
func TestRenewToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/auth/login":
writeJSON(w, http.StatusOK, map[string]string{
"token": "tok-old",
"expires_at": "2099-01-01T00:00:00Z",
})
case "/v1/auth/renew":
writeJSON(w, http.StatusOK, map[string]string{
"token": "tok-new",
"expires_at": "2099-06-01T00:00:00Z",
})
default:
http.Error(w, "not found", http.StatusNotFound)
}
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
if _, _, err := c.Login("alice", "pass", ""); err != nil {
t.Fatalf("Login: %v", err)
}
tok, _, err := c.RenewToken()
if err != nil {
t.Fatalf("RenewToken: %v", err)
}
if tok != "tok-new" {
t.Errorf("expected tok-new, got %q", tok)
}
if c.Token() != "tok-new" {
t.Errorf("Token() = %q, want tok-new", c.Token())
}
}
// ---------------------------------------------------------------------------
// TestValidateToken
// ---------------------------------------------------------------------------
func TestValidateToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/token/validate" {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": true,
"sub": "user-uuid-1",
"roles": []string{"admin"},
"expires_at": "2099-01-01T00:00:00Z",
})
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
claims, err := c.ValidateToken("some.jwt.token")
if err != nil {
t.Fatalf("ValidateToken: %v", err)
}
if !claims.Valid {
t.Error("expected claims.Valid = true")
}
if claims.Sub != "user-uuid-1" {
t.Errorf("expected sub=user-uuid-1, got %q", claims.Sub)
}
}
// ---------------------------------------------------------------------------
// TestValidateTokenInvalid
// ---------------------------------------------------------------------------
func TestValidateTokenInvalid(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Server returns 200 with valid=false for an expired/revoked token.
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": false,
})
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
claims, err := c.ValidateToken("expired.jwt.token")
if err != nil {
t.Fatalf("ValidateToken: unexpected error: %v", err)
}
if claims.Valid {
t.Error("expected claims.Valid = false")
}
}
// ---------------------------------------------------------------------------
// TestCreateAccount
// ---------------------------------------------------------------------------
func TestCreateAccount(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/accounts" || r.Method != http.MethodPost {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": "acct-uuid-1",
"username": "bob",
"account_type": "human",
"status": "active",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"totp_enabled": false,
})
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
acct, err := c.CreateAccount("bob", "human", "pass123")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acct.ID != "acct-uuid-1" {
t.Errorf("expected id=acct-uuid-1, got %q", acct.ID)
}
if acct.Username != "bob" {
t.Errorf("expected username=bob, got %q", acct.Username)
}
}
// ---------------------------------------------------------------------------
// TestCreateAccountConflict
// ---------------------------------------------------------------------------
func TestCreateAccountConflict(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, "username already exists")
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
_, err := c.CreateAccount("bob", "human", "pass123")
if err == nil {
t.Fatal("expected error for 409")
}
var conflictErr *mciasgoclient.MciasConflictError
if !errors.As(err, &conflictErr) {
t.Errorf("expected MciasConflictError, got %T: %v", err, err)
}
}
// ---------------------------------------------------------------------------
// TestListAccounts
// ---------------------------------------------------------------------------
func TestListAccounts(t *testing.T) {
accounts := []map[string]interface{}{
{
"id": "acct-1", "username": "alice", "account_type": "human",
"status": "active", "created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z", "totp_enabled": false,
},
{
"id": "acct-2", "username": "bob", "account_type": "human",
"status": "active", "created_at": "2024-01-02T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z", "totp_enabled": false,
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/accounts" || r.Method != http.MethodGet {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, accounts)
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
list, err := c.ListAccounts()
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
if len(list) != 2 {
t.Errorf("expected 2 accounts, got %d", len(list))
}
if list[0].Username != "alice" {
t.Errorf("expected alice, got %q", list[0].Username)
}
}
// ---------------------------------------------------------------------------
// TestGetAccount
// ---------------------------------------------------------------------------
func TestGetAccount(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !strings.HasPrefix(r.URL.Path, "/v1/accounts/") {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": "acct-uuid-42",
"username": "carol",
"account_type": "human",
"status": "active",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"totp_enabled": false,
})
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
acct, err := c.GetAccount("acct-uuid-42")
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if acct.ID != "acct-uuid-42" {
t.Errorf("expected acct-uuid-42, got %q", acct.ID)
}
}
// ---------------------------------------------------------------------------
// TestUpdateAccount
// ---------------------------------------------------------------------------
func TestUpdateAccount(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": "acct-uuid-42",
"username": "carol",
"account_type": "human",
"status": "disabled",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-02-01T00:00:00Z",
"totp_enabled": false,
})
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
acct, err := c.UpdateAccount("acct-uuid-42", "disabled")
if err != nil {
t.Fatalf("UpdateAccount: %v", err)
}
if acct.Status != "disabled" {
t.Errorf("expected status=disabled, got %q", acct.Status)
}
}
// ---------------------------------------------------------------------------
// TestDeleteAccount
// ---------------------------------------------------------------------------
func TestDeleteAccount(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
if err := c.DeleteAccount("acct-uuid-42"); err != nil {
t.Fatalf("DeleteAccount: unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// TestGetRoles
// ---------------------------------------------------------------------------
func TestGetRoles(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !strings.HasSuffix(r.URL.Path, "/roles") {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"roles": []string{"admin", "viewer"},
})
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
roles, err := c.GetRoles("acct-uuid-42")
if err != nil {
t.Fatalf("GetRoles: %v", err)
}
if len(roles) != 2 {
t.Errorf("expected 2 roles, got %d", len(roles))
}
if roles[0] != "admin" {
t.Errorf("expected roles[0]=admin, got %q", roles[0])
}
}
// ---------------------------------------------------------------------------
// TestSetRoles
// ---------------------------------------------------------------------------
func TestSetRoles(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
if err := c.SetRoles("acct-uuid-42", []string{"admin"}); err != nil {
t.Fatalf("SetRoles: unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// TestIssueServiceToken
// ---------------------------------------------------------------------------
func TestIssueServiceToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/token/issue" || r.Method != http.MethodPost {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"token": "svc-tok-xyz",
"expires_at": "2099-01-01T00:00:00Z",
})
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
tok, exp, err := c.IssueServiceToken("svc-uuid-1")
if err != nil {
t.Fatalf("IssueServiceToken: %v", err)
}
if tok != "svc-tok-xyz" {
t.Errorf("expected svc-tok-xyz, got %q", tok)
}
if exp == "" {
t.Error("expected non-empty expires_at")
}
}
// ---------------------------------------------------------------------------
// TestRevokeToken
// ---------------------------------------------------------------------------
func TestRevokeToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !strings.HasPrefix(r.URL.Path, "/v1/token/") {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
if err := c.RevokeToken("jti-abc123"); err != nil {
t.Fatalf("RevokeToken: unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// TestGetPGCreds
// ---------------------------------------------------------------------------
func TestGetPGCreds(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !strings.HasSuffix(r.URL.Path, "/pgcreds") {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"host": "db.example.com",
"port": 5432,
"database": "myapp",
"username": "appuser",
"password": "secretpw",
})
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
creds, err := c.GetPGCreds("acct-uuid-42")
if err != nil {
t.Fatalf("GetPGCreds: %v", err)
}
if creds.Host != "db.example.com" {
t.Errorf("expected host=db.example.com, got %q", creds.Host)
}
if creds.Port != 5432 {
t.Errorf("expected port=5432, got %d", creds.Port)
}
if creds.Password != "secretpw" {
t.Errorf("expected password=secretpw, got %q", creds.Password)
}
}
// ---------------------------------------------------------------------------
// TestSetPGCreds
// ---------------------------------------------------------------------------
func TestSetPGCreds(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !strings.HasSuffix(r.URL.Path, "/pgcreds") {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
err := c.SetPGCreds("acct-uuid-42", "db.example.com", 5432, "myapp", "appuser", "secretpw")
if err != nil {
t.Fatalf("SetPGCreds: unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// TestIntegration: full login → validate → logout flow
// ---------------------------------------------------------------------------
func TestIntegration(t *testing.T) {
const sessionToken = "integration-tok-999"
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad request")
return
}
if body.Username != "alice" || body.Password != "correct-horse" {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"token": sessionToken,
"expires_at": "2099-01-01T00:00:00Z",
})
})
mux.HandleFunc("/v1/token/validate", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad request")
return
}
if body.Token == sessionToken {
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": true,
"sub": "alice-uuid",
"roles": []string{"user"},
"expires_at": "2099-01-01T00:00:00Z",
})
} else {
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": false,
})
}
})
mux.HandleFunc("/v1/auth/logout", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Verify Authorization header is present.
auth := r.Header.Get("Authorization")
if auth == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := newTestClient(t, srv.URL)
// Step 1: login with wrong credentials should fail.
_, _, err := c.Login("alice", "wrong-password", "")
if err == nil {
t.Fatal("expected error for wrong credentials")
}
var authErr *mciasgoclient.MciasAuthError
if !errors.As(err, &authErr) {
t.Errorf("expected MciasAuthError, got %T", err)
}
// Step 2: login with correct credentials.
tok, _, err := c.Login("alice", "correct-horse", "")
if err != nil {
t.Fatalf("Login: %v", err)
}
if tok != sessionToken {
t.Errorf("expected %q, got %q", sessionToken, tok)
}
// Step 3: validate the returned token.
claims, err := c.ValidateToken(tok)
if err != nil {
t.Fatalf("ValidateToken: %v", err)
}
if !claims.Valid {
t.Error("expected Valid=true after login")
}
if claims.Sub != "alice-uuid" {
t.Errorf("expected sub=alice-uuid, got %q", claims.Sub)
}
// Step 4: validate an unknown token returns Valid=false, not an error.
claims2, err := c.ValidateToken("garbage-token")
if err != nil {
t.Fatalf("ValidateToken(garbage): unexpected error: %v", err)
}
if claims2.Valid {
t.Error("expected Valid=false for garbage token")
}
// Step 5: logout clears the stored token.
if err := c.Logout(); err != nil {
t.Fatalf("Logout: %v", err)
}
if c.Token() != "" {
t.Errorf("expected empty token after logout, got %q", c.Token())
}
}

3
clients/go/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.wntrmute.dev/kyle/mcias/clients/go
go 1.21

0
clients/go/go.sum Normal file
View File

102
clients/lisp/README.md Normal file
View File

@@ -0,0 +1,102 @@
# mcias-client (Common Lisp)
Common Lisp client library for the [MCIAS](../../README.md) identity and access management API.
## Requirements
- SBCL 2.x (primary), CCL (secondary)
- Quicklisp
## Installation
Place the `clients/lisp/` directory on ASDF's central registry or load it
via Quicklisp local-projects:
```sh
ln -s /path/to/mcias/clients/lisp ~/.quicklisp/local-projects/mcias-client
```
Then in your Lisp image:
```lisp
(ql:quickload :mcias-client)
```
## Quick Start
```lisp
(use-package :mcias-client)
;; Connect to the MCIAS server.
(defvar *client* (make-client "https://auth.example.com"))
;; Authenticate.
(multiple-value-bind (token expires-at)
(login *client* "alice" "s3cret")
(format t "token expires at ~A~%" expires-at))
;; The token is stored in the client automatically.
(let ((accounts (list-accounts *client*)))
(format t "~A accounts~%" (length accounts)))
;; Revoke the token when done.
(logout *client*)
```
## Custom CA Certificate
```lisp
(defvar *client*
(make-client "https://auth.example.com"
:ca-cert "/etc/mcias/ca.pem"))
```
## Error Handling
All functions signal typed conditions on error:
```lisp
(handler-case
(login *client* "alice" "wrongpass")
(mcias-auth-error (e)
(format t "auth failed: ~A~%" (mcias-error-message e)))
(mcias-forbidden-error (e)
(format t "forbidden: ~A~%" (mcias-error-message e)))
(mcias-not-found-error (e)
(format t "not found: ~A~%" (mcias-error-message e)))
(mcias-input-error (e)
(format t "bad input: ~A~%" (mcias-error-message e)))
(mcias-conflict-error (e)
(format t "conflict: ~A~%" (mcias-error-message e)))
(mcias-server-error (e)
(format t "server error ~A: ~A~%"
(mcias-error-status e)
(mcias-error-message e))))
```
All condition types are subclasses of `mcias-error`, which has slots:
- `mcias-error-status` — HTTP status code (integer)
- `mcias-error-message` — server error message (string)
## `validate-token` Return Value
`validate-token` returns a property list. The `:valid` key is `T` if the
token is valid, `NIL` otherwise (never raises an error for an invalid token):
```lisp
(let ((result (validate-token *client* some-token)))
(if (getf result :valid)
(format t "valid; sub=~A~%" (getf result :sub))
(format t "invalid~%")))
```
## Running Tests
```sh
sbcl --non-interactive \
--eval '(require :asdf)' \
--eval "(push #P\"$(pwd)/\" asdf:*central-registry*)" \
--eval '(ql:quickload :mcias-client/tests :silent t)' \
--eval '(mcias-client-tests:run-all-tests)' \
--eval '(uiop:quit)'
```

288
clients/lisp/client.lisp Normal file
View File

@@ -0,0 +1,288 @@
;;;; client.lisp -- MCIAS REST API client implementation
(in-package #:mcias-client)
;;;; -----------------------------------------------------------------------
;;;; Client class
;;;; -----------------------------------------------------------------------
(defclass mcias-client ()
((base-url :initarg :base-url
:reader client-base-url
:documentation "Base URL of the MCIAS server (no trailing slash).")
(token :initarg :token
:initform nil
:accessor client-token
:documentation "Current Bearer token string, or NIL.")
(ca-cert :initarg :ca-cert
:initform nil
:reader client-ca-cert
:documentation "Path to CA certificate file for TLS verification, or NIL."))
(:documentation "Holds connection parameters for one MCIAS server."))
(defun make-client (base-url &key token ca-cert)
"Create an MCIAS client for BASE-URL.
Optional TOKEN pre-seeds the Bearer token; CA-CERT overrides TLS CA."
;; Strip any trailing slashes so we can always append /v1/... cleanly.
(let ((url (string-right-trim "/" base-url)))
(make-instance 'mcias-client
:base-url url
:token token
:ca-cert ca-cert)))
;;;; -----------------------------------------------------------------------
;;;; Internal helpers
;;;; -----------------------------------------------------------------------
(defun %encode-json (object)
"Encode OBJECT to a JSON string using yason."
(with-output-to-string (s)
(yason:encode object s)))
(defun %parse-json (string)
"Parse STRING as JSON. Returns NIL for empty or nil input."
(when (and string (> (length string) 0))
(yason:parse string)))
(defun %auth-headers (client)
"Return an alist of HTTP headers for CLIENT.
Includes Authorization: Bearer <token> when a token is set."
(let ((headers (list (cons "Content-Type" "application/json")
(cons "Accept" "application/json"))))
(when (client-token client)
(push (cons "Authorization"
(concatenate 'string "Bearer " (client-token client)))
headers))
headers))
(defun %check-status (status body-string)
"Signal an appropriate mcias-error if STATUS >= 400.
Extracts the 'error' field from the JSON body when possible."
(when (>= status 400)
(let* ((parsed (ignore-errors (%parse-json body-string)))
(message (if (hash-table-p parsed)
(or (gethash "error" parsed) body-string)
body-string)))
(signal-mcias-error status message))))
(defun %request (client method path &key body)
"Perform an HTTP request against the MCIAS server.
METHOD is a keyword (:GET :POST etc.), PATH is the API path string.
BODY (optional) is a hash table or list that will be JSON-encoded.
Returns the parsed JSON response body (hash-table/list/string/number)
or NIL for empty responses."
(let* ((url (concatenate 'string (client-base-url client) path))
(headers (%auth-headers client))
(content (when body (%encode-json body))))
(multiple-value-bind (resp-body status)
(handler-case
(dex:request url
:method method
:headers headers
:content content
:want-stream nil
:force-string t)
(dex:http-request-failed (e)
(values (dex:response-body e)
(dex:response-status e))))
(%check-status status resp-body)
(%parse-json resp-body))))
;;;; -----------------------------------------------------------------------
;;;; Account response helper
;;;; -----------------------------------------------------------------------
(defun %account->plist (ht)
"Convert a yason-parsed account hash-table HT to a plist."
(when ht
(list :id (gethash "id" ht)
:username (gethash "username" ht)
:account-type (gethash "account_type" ht)
:status (gethash "status" ht)
:created-at (gethash "created_at" ht)
:updated-at (gethash "updated_at" ht))))
;;;; -----------------------------------------------------------------------
;;;; Authentication
;;;; -----------------------------------------------------------------------
(defun login (client username password &key totp-code)
"Authenticate USERNAME/PASSWORD against MCIAS.
Stores the returned token in CLIENT-TOKEN.
Returns (values token expires-at)."
(let* ((body (let ((ht (make-hash-table :test 'equal)))
(setf (gethash "username" ht) username
(gethash "password" ht) password)
(when totp-code
(setf (gethash "totp_code" ht) totp-code))
ht))
(resp (%request client :post "/v1/auth/login" :body body))
(token (gethash "token" resp))
(expires-at (gethash "expires_at" resp)))
(setf (client-token client) token)
(values token expires-at)))
(defun logout (client)
"Revoke the current session token and clear CLIENT-TOKEN.
Returns T on success."
(%request client :post "/v1/auth/logout")
(setf (client-token client) nil)
t)
(defun renew-token (client)
"Renew the current Bearer token.
Stores the new token in CLIENT-TOKEN.
Returns (values new-token expires-at)."
(let* ((resp (%request client :post "/v1/auth/renew" :body (make-hash-table :test 'equal)))
(token (gethash "token" resp))
(expires-at (gethash "expires_at" resp)))
(setf (client-token client) token)
(values token expires-at)))
(defun validate-token (client token-string)
"Validate TOKEN-STRING with the MCIAS server.
Returns a plist with :valid :sub :roles :expires-at.
:valid is T for a valid token, NIL for invalid (not an error condition)."
(let* ((body (let ((ht (make-hash-table :test 'equal)))
(setf (gethash "token" ht) token-string)
ht))
(resp (%request client :post "/v1/token/validate" :body body))
;; yason parses JSON true -> T, JSON false -> :FALSE
(raw-valid (gethash "valid" resp))
(valid (if (eq raw-valid t) t nil)))
(list :valid valid
:sub (gethash "sub" resp)
:roles (gethash "roles" resp)
:expires-at (gethash "expires_at" resp))))
;;;; -----------------------------------------------------------------------
;;;; Server information
;;;; -----------------------------------------------------------------------
(defun health (client)
"Check server health. Returns T on success, signals on failure."
(%request client :get "/v1/health")
t)
(defun get-public-key (client)
"Fetch the server's public key (JWK).
Returns a plist with :kty :crv :x."
(let ((resp (%request client :get "/v1/keys/public")))
(list :kty (gethash "kty" resp)
:crv (gethash "crv" resp)
:x (gethash "x" resp))))
;;;; -----------------------------------------------------------------------
;;;; Account management (admin)
;;;; -----------------------------------------------------------------------
(defun create-account (client username account-type &key password)
"Create a new account. Requires admin token.
Returns an account plist."
(let ((body (let ((ht (make-hash-table :test 'equal)))
(setf (gethash "username" ht) username
(gethash "account_type" ht) account-type)
(when password
(setf (gethash "password" ht) password))
ht)))
(%account->plist (%request client :post "/v1/accounts" :body body))))
(defun list-accounts (client)
"List all accounts. Requires admin token.
Returns a list of account plists."
(let ((resp (%request client :get "/v1/accounts")))
;; Response is a JSON array
(mapcar #'%account->plist resp)))
(defun get-account (client id)
"Get account by ID. Requires admin token.
Returns an account plist."
(%account->plist (%request client :get (format nil "/v1/accounts/~A" id))))
(defun update-account (client id &key status)
"Update account fields. Requires admin token.
Returns updated account plist."
(let ((body (let ((ht (make-hash-table :test 'equal)))
(when status
(setf (gethash "status" ht) status))
ht)))
(%account->plist (%request client :patch
(format nil "/v1/accounts/~A" id)
:body body))))
(defun delete-account (client id)
"Delete account by ID. Requires admin token.
Returns T on success."
(%request client :delete (format nil "/v1/accounts/~A" id))
t)
;;;; -----------------------------------------------------------------------
;;;; Role management (admin)
;;;; -----------------------------------------------------------------------
(defun get-roles (client account-id)
"Get roles for ACCOUNT-ID. Requires admin token.
Returns a list of role strings."
(let ((resp (%request client :get (format nil "/v1/accounts/~A/roles" account-id))))
(gethash "roles" resp)))
(defun set-roles (client account-id roles)
"Set roles for ACCOUNT-ID to ROLES (list of strings). Requires admin token.
Returns T on success."
(let ((body (let ((ht (make-hash-table :test 'equal)))
(setf (gethash "roles" ht) roles)
ht)))
(%request client :put
(format nil "/v1/accounts/~A/roles" account-id)
:body body)
t))
;;;; -----------------------------------------------------------------------
;;;; Token management (admin)
;;;; -----------------------------------------------------------------------
(defun issue-service-token (client account-id)
"Issue a service token for ACCOUNT-ID. Requires admin token.
Returns (values token expires-at)."
(let* ((body (let ((ht (make-hash-table :test 'equal)))
(setf (gethash "account_id" ht) account-id)
ht))
(resp (%request client :post "/v1/token/issue" :body body))
(token (gethash "token" resp))
(expires-at (gethash "expires_at" resp)))
(values token expires-at)))
(defun revoke-token (client jti)
"Revoke token by JTI. Requires admin token.
Returns T on success."
(%request client :delete (format nil "/v1/token/~A" jti))
t)
;;;; -----------------------------------------------------------------------
;;;; PG credentials (admin)
;;;; -----------------------------------------------------------------------
(defun get-pg-creds (client account-id)
"Get PostgreSQL credentials for ACCOUNT-ID. Requires admin token.
Returns a plist with :host :port :database :username :password."
(let ((resp (%request client :get (format nil "/v1/accounts/~A/pgcreds" account-id))))
(list :host (gethash "host" resp)
:port (gethash "port" resp)
:database (gethash "database" resp)
:username (gethash "username" resp)
:password (gethash "password" resp))))
(defun set-pg-creds (client account-id host port database username password)
"Set PostgreSQL credentials for ACCOUNT-ID. Requires admin token.
Returns T on success."
(let ((body (let ((ht (make-hash-table :test 'equal)))
(setf (gethash "host" ht) host
(gethash "port" ht) port
(gethash "database" ht) database
(gethash "username" ht) username
(gethash "password" ht) password)
ht)))
(%request client :put
(format nil "/v1/accounts/~A/pgcreds" account-id)
:body body)
t))

View File

@@ -0,0 +1,37 @@
;;;; conditions.lisp -- MCIAS error condition hierarchy
(in-package #:mcias-client)
(define-condition mcias-error (error)
((status :initarg :status :reader mcias-error-status
:documentation "HTTP status code (integer).")
(message :initarg :message :reader mcias-error-message
:documentation "Server error message string."))
(:report (lambda (c s)
(format s "MCIAS error ~A: ~A"
(mcias-error-status c)
(mcias-error-message c))))
(:documentation "Base condition for all MCIAS API errors."))
(define-condition mcias-auth-error (mcias-error) ()
(:documentation "401 Unauthorized -- token missing, invalid, or expired."))
(define-condition mcias-forbidden-error (mcias-error) ()
(:documentation "403 Forbidden -- insufficient role."))
(define-condition mcias-not-found-error (mcias-error) ()
(:documentation "404 Not Found -- resource does not exist."))
(define-condition mcias-input-error (mcias-error) ()
(:documentation "400 Bad Request -- malformed request."))
(define-condition mcias-conflict-error (mcias-error) ()
(:documentation "409 Conflict -- e.g. duplicate username."))
(define-condition mcias-server-error (mcias-error) ()
(:documentation "5xx -- unexpected server error."))
(defun signal-mcias-error (status message)
"Signal the appropriate MCIAS condition for STATUS (integer) and MESSAGE (string)."
(case status
(401 (error 'mcias-auth-error :status status :message message))
(403 (error 'mcias-forbidden-error :status status :message message))
(404 (error 'mcias-not-found-error :status status :message message))
(400 (error 'mcias-input-error :status status :message message))
(409 (error 'mcias-conflict-error :status status :message message))
(t (error 'mcias-server-error :status status :message message))))

View File

@@ -0,0 +1,25 @@
(defsystem "mcias-client"
:version "0.1.0"
:author "Kyle Isom"
:description "Common Lisp client for the MCIAS identity and access management API"
:license "MIT"
:depends-on ("dexador"
"yason"
"cl-ppcre"
"alexandria")
:components ((:file "package")
(:file "conditions" :depends-on ("package"))
(:file "client" :depends-on ("package" "conditions")))
:in-order-to ((test-op (test-op "mcias-client/tests"))))
(defsystem "mcias-client/tests"
:version "0.1.0"
:description "Tests for mcias-client"
:depends-on ("mcias-client"
"fiveam"
"hunchentoot"
"babel")
:components ((:file "tests/package")
(:file "tests/mock-server" :depends-on ("tests/package"))
(:file "tests/client-tests" :depends-on ("tests/package" "tests/mock-server")))
:perform (test-op (op c)
(uiop:symbol-call :mcias-client-tests :run-all-tests)))

49
clients/lisp/package.lisp Normal file
View File

@@ -0,0 +1,49 @@
;;;; package.lisp -- package definition for mcias-client
(defpackage #:mcias-client
(:use #:cl)
(:export
;; Client construction
#:make-client
#:client-base-url
#:client-token
;; Conditions
#:mcias-error
#:mcias-auth-error
#:mcias-forbidden-error
#:mcias-not-found-error
#:mcias-input-error
#:mcias-conflict-error
#:mcias-server-error
#:mcias-error-status
#:mcias-error-message
;; Authentication
#:login
#:logout
#:renew-token
#:validate-token
;; Server information
#:health
#:get-public-key
;; Account management (admin)
#:create-account
#:list-accounts
#:get-account
#:update-account
#:delete-account
;; Role management (admin)
#:get-roles
#:set-roles
;; Token management (admin)
#:issue-service-token
#:revoke-token
;; PG credentials (admin)
#:get-pg-creds
#:set-pg-creds))

View File

@@ -0,0 +1,201 @@
;;;; tests/client-tests.lisp -- fiveam test suite for mcias-client
(in-package #:mcias-client-tests)
;;;; -----------------------------------------------------------------------
;;;; Test suite
;;;; -----------------------------------------------------------------------
(fiveam:def-suite mcias-client-suite
:description "Tests for the mcias-client library")
(fiveam:in-suite mcias-client-suite)
;;;; -----------------------------------------------------------------------
;;;; Helper macro
;;;; -----------------------------------------------------------------------
(defmacro with-mock-server ((client-var &key admin-token) &body body)
"Spin up a fresh mock server, bind CLIENT-VAR, run BODY, then stop."
(let ((port-var (gensym "PORT"))
(server-url (gensym "URL")))
`(let* ((,port-var (start-mock-server))
(,server-url (format nil "http://localhost:~A" ,port-var))
(,client-var (make-client ,server-url :token ,admin-token)))
(unwind-protect
(progn ,@body)
(stop-mock-server)))))
;;;; -----------------------------------------------------------------------
;;;; Condition hierarchy tests
;;;; -----------------------------------------------------------------------
(fiveam:test condition-hierarchy
"Verify the condition type hierarchy."
(fiveam:is (subtypep 'mcias-auth-error 'mcias-error))
(fiveam:is (subtypep 'mcias-forbidden-error 'mcias-error))
(fiveam:is (subtypep 'mcias-not-found-error 'mcias-error))
(fiveam:is (subtypep 'mcias-input-error 'mcias-error))
(fiveam:is (subtypep 'mcias-conflict-error 'mcias-error))
(fiveam:is (subtypep 'mcias-server-error 'mcias-error)))
;;;; -----------------------------------------------------------------------
;;;; make-client tests
;;;; -----------------------------------------------------------------------
(fiveam:test make-client-basic
"make-client stores base-url and token."
(let ((c (make-client "http://localhost:9000" :token "tok123")))
(fiveam:is (string= "http://localhost:9000" (client-base-url c)))
(fiveam:is (string= "tok123" (client-token c)))))
(fiveam:test make-client-strips-trailing-slash
"make-client trims trailing slashes from the URL."
(let ((c (make-client "http://localhost:9000///")))
(fiveam:is (string= "http://localhost:9000" (client-base-url c)))))
(fiveam:test make-client-no-token
"make-client with no :token gives NIL token."
(let ((c (make-client "http://localhost:9000")))
(fiveam:is (null (client-token c)))))
;;;; -----------------------------------------------------------------------
;;;; Server info tests
;;;; -----------------------------------------------------------------------
(fiveam:test health-ok
"health returns T for a live server."
(with-mock-server (c)
(fiveam:is (eq t (health c)))))
(fiveam:test get-public-key
"get-public-key returns a plist with :kty :crv :x."
(with-mock-server (c)
(let ((jwk (get-public-key c)))
(fiveam:is (string= "OKP" (getf jwk :kty)))
(fiveam:is (string= "Ed25519" (getf jwk :crv)))
(fiveam:is (stringp (getf jwk :x))))))
;;;; -----------------------------------------------------------------------
;;;; Authentication tests
;;;; -----------------------------------------------------------------------
(fiveam:test login-success
"Successful login returns a token and stores it in the client."
(with-mock-server (c)
(multiple-value-bind (token expires-at)
(login c "admin" "adminpass")
(fiveam:is (stringp token))
(fiveam:is (stringp expires-at))
(fiveam:is (string= token (client-token c))))))
(fiveam:test login-bad-password
"Wrong password signals mcias-auth-error."
(with-mock-server (c)
(fiveam:signals mcias-auth-error
(login c "admin" "wrongpassword"))))
(fiveam:test login-unknown-user
"Unknown username signals mcias-auth-error."
(with-mock-server (c)
(fiveam:signals mcias-auth-error
(login c "nosuchuser" "whatever"))))
(fiveam:test logout-clears-token
"logout revokes the token server-side and sets client-token to NIL."
(with-mock-server (c)
(login c "admin" "adminpass")
(fiveam:is (stringp (client-token c)))
(fiveam:is (eq t (logout c)))
(fiveam:is (null (client-token c)))))
(fiveam:test renew-token
"renew-token replaces the stored token."
(with-mock-server (c)
(login c "admin" "adminpass")
(let ((old-token (client-token c)))
(multiple-value-bind (new-token expires-at)
(renew-token c)
(fiveam:is (stringp new-token))
(fiveam:is (stringp expires-at))
(fiveam:is (not (string= old-token new-token)))
(fiveam:is (string= new-token (client-token c)))))))
;;;; -----------------------------------------------------------------------
;;;; Token validation tests
;;;; -----------------------------------------------------------------------
(fiveam:test validate-token-valid
"validate-token returns :valid T for a live token."
(with-mock-server (c)
(multiple-value-bind (token _expires)
(login c "admin" "adminpass")
(declare (ignore _expires))
(let ((result (validate-token c token)))
(fiveam:is (eq t (getf result :valid)))
(fiveam:is (stringp (getf result :sub)))))))
(fiveam:test validate-token-after-logout
"validate-token returns :valid NIL for a revoked token (not an error)."
(with-mock-server (c)
(login c "admin" "adminpass")
(let ((token (client-token c)))
(logout c)
(let ((result (validate-token c token)))
(fiveam:is (null (getf result :valid)))))))
(fiveam:test validate-token-garbage
"validate-token returns :valid NIL for a garbage token string."
(with-mock-server (c)
(let ((result (validate-token c "garbage-token-xyz")))
(fiveam:is (null (getf result :valid))))))
;;;; -----------------------------------------------------------------------
;;;; Account management tests
;;;; -----------------------------------------------------------------------
(fiveam:test create-account
"create-account returns a plist with :id :username :status."
(with-mock-server (c)
(login c "admin" "adminpass")
(let ((acct (create-account c "newuser" "user" :password "pass123")))
(fiveam:is (stringp (getf acct :id)))
(fiveam:is (string= "newuser" (getf acct :username)))
(fiveam:is (stringp (getf acct :status))))))
(fiveam:test list-accounts
"list-accounts returns a list with at least the admin account."
(with-mock-server (c)
(login c "admin" "adminpass")
(let ((accounts (list-accounts c)))
(fiveam:is (listp accounts))
(fiveam:is (>= (length accounts) 1)))))
;;;; -----------------------------------------------------------------------
;;;; End-to-end lifecycle test
;;;; -----------------------------------------------------------------------
(fiveam:test e2e-login-validate-logout
"Full lifecycle: login -> validate (valid) -> logout -> validate (invalid)."
(with-mock-server (c)
(multiple-value-bind (token _)
(login c "admin" "adminpass")
(declare (ignore _))
;; Token should be valid right after login
(let ((r1 (validate-token c token)))
(fiveam:is (eq t (getf r1 :valid))))
;; Logout revokes the token
(logout c)
;; Token should now be invalid (not an error)
(let ((r2 (validate-token c token)))
(fiveam:is (null (getf r2 :valid)))))))
;;;; -----------------------------------------------------------------------
;;;; Entry point
;;;; -----------------------------------------------------------------------
(defun run-all-tests ()
"Run all mcias-client tests. Returns T if all pass."
(let ((results (fiveam:run 'mcias-client-suite)))
(fiveam:explain! results)
(fiveam:results-status results)))

View File

@@ -0,0 +1,409 @@
;;;; tests/mock-server.lisp -- Hunchentoot-based mock MCIAS server
(in-package #:mcias-client-tests)
;;;; -----------------------------------------------------------------------
;;;; Global state
;;;; -----------------------------------------------------------------------
(defvar *mock-server* nil "The running Hunchentoot acceptor.")
(defvar *mock-accounts* nil "Hash table: id -> account plist.")
(defvar *mock-by-name* nil "Hash table: username -> id.")
(defvar *mock-tokens* nil "Hash table: token-string -> account-id.")
(defvar *mock-revoked* nil "Hash table: token-string -> t (revoked tokens).")
(defvar *mock-pgcreds* nil "Hash table: account-id -> pgcreds plist.")
(defun reset-mock-state! ()
"Reset all mock server state to empty."
(setf *mock-accounts* (make-hash-table :test 'equal)
*mock-by-name* (make-hash-table :test 'equal)
*mock-tokens* (make-hash-table :test 'equal)
*mock-revoked* (make-hash-table :test 'equal)
*mock-pgcreds* (make-hash-table :test 'equal)))
;; Initialise state immediately so the vars are never NIL.
(reset-mock-state!)
;;;; -----------------------------------------------------------------------
;;;; Mock data helpers
;;;; -----------------------------------------------------------------------
(let ((id-counter 0))
(defun %next-id ()
(incf id-counter)
(format nil "acct-~4,'0D" id-counter)))
(defun add-mock-account (username password account-type &rest roles)
"Add a mock account and return its ID string."
(let ((id (format nil "acct-~A" (gensym ""))))
(setf (gethash id *mock-accounts*)
(list :id id
:username username
:password password
:account-type account-type
:status "active"
:roles (or roles '())
:totp-enabled nil
:created-at "2024-01-01T00:00:00Z"
:updated-at "2024-01-01T00:00:00Z"))
(setf (gethash username *mock-by-name*) id)
id))
(defun %issue-mock-token (account-id)
"Create and store a mock token for ACCOUNT-ID. Returns the token string."
(let ((token (format nil "mock-token-~A-~A" account-id (gensym ""))))
(setf (gethash token *mock-tokens*) account-id)
token))
;;;; -----------------------------------------------------------------------
;;;; Response helpers (used inside Hunchentoot handlers)
;;;; -----------------------------------------------------------------------
(defun %yason-encode (obj)
"Encode OBJ to a JSON string."
(with-output-to-string (s)
(yason:encode obj s)))
(defun %send-json (status body-string)
"Set the HTTP status code and content-type, then return BODY-STRING."
(setf (hunchentoot:return-code*) status
(hunchentoot:content-type*) "application/json")
body-string)
(defun %send-ok (ht)
"Send a 200 response with HT (hash-table) encoded as JSON."
(%send-json 200 (%yason-encode ht)))
(defun %send-no-content ()
"Send a 204 No Content response."
(setf (hunchentoot:return-code*) 204
(hunchentoot:content-type*) "application/json")
"")
(defun %send-error (code message)
"Send an error response with CODE and MESSAGE."
(let ((ht (make-hash-table :test 'equal)))
(setf (gethash "error" ht) message)
(%send-json code (%yason-encode ht))))
(defun %read-json-body ()
"Read and parse the raw POST body as JSON. Returns NIL on failure."
(let ((raw (hunchentoot:raw-post-data :force-binary t)))
(when raw
(ignore-errors
(yason:parse (babel:octets-to-string raw :encoding :utf-8))))))
(defun %bearer-token ()
"Extract the Bearer token from the Authorization header, or NIL."
(let ((auth (hunchentoot:header-in* :authorization)))
(when (and auth (> (length auth) 7)
(string-equal (subseq auth 0 7) "Bearer "))
(subseq auth 7))))
(defun %authenticated-account ()
"Return the account plist for the current Bearer token, or NIL."
(let ((token (%bearer-token)))
(when token
(unless (gethash token *mock-revoked*)
(let ((account-id (gethash token *mock-tokens*)))
(when account-id
(gethash account-id *mock-accounts*)))))))
(defun %require-admin ()
"Return the authenticated account if it has the 'admin' role.
Sends 401 or 403 and returns NIL otherwise."
(let ((acct (%authenticated-account)))
(cond
((null acct)
(%send-error 401 "unauthorized")
nil)
((not (member "admin" (getf acct :roles) :test #'string=))
(%send-error 403 "forbidden")
nil)
(t acct))))
(defun %account->hash (acct)
"Convert internal account plist ACCT to a yason-encodable hash-table."
(let ((ht (make-hash-table :test 'equal)))
(setf (gethash "id" ht) (getf acct :id)
(gethash "username" ht) (getf acct :username)
(gethash "account_type" ht) (getf acct :account-type)
(gethash "status" ht) (getf acct :status)
(gethash "created_at" ht) (getf acct :created-at)
(gethash "updated_at" ht) (getf acct :updated-at)
;; yason: nil -> JSON false, t -> JSON true
(gethash "totp_enabled" ht) (if (getf acct :totp-enabled) t nil))
ht))
;;;; -----------------------------------------------------------------------
;;;; Dispatcher
;;;; -----------------------------------------------------------------------
(defclass mock-dispatcher (hunchentoot:acceptor) ()
(:documentation "Custom Hunchentoot acceptor that dispatches mock MCIAS routes."))
(defun %path= (path expected)
"Check if PATH equals EXPECTED (case-insensitive)."
(string-equal path expected))
(defun %path-prefix-p (path prefix)
"Check if PATH starts with PREFIX."
(and (>= (length path) (length prefix))
(string-equal (subseq path 0 (length prefix)) prefix)))
(defun %path-segment (path n)
"Return the Nth segment of PATH (0-indexed), split by /."
(let ((parts (remove "" (cl-ppcre:split "/" path) :test #'string=)))
(when (< n (length parts))
(nth n parts))))
(defmethod hunchentoot:handle-request ((acceptor mock-dispatcher) request)
"Dispatch requests to mock MCIAS handlers."
(let ((method (hunchentoot:request-method request))
(path (hunchentoot:script-name request)))
(cond
;; GET /v1/health
((and (eq method :get) (%path= path "/v1/health"))
(let ((ht (make-hash-table :test 'equal)))
(setf (gethash "status" ht) "ok")
(%send-ok ht)))
;; GET /v1/keys/public
((and (eq method :get) (%path= path "/v1/keys/public"))
(let ((ht (make-hash-table :test 'equal)))
(setf (gethash "kty" ht) "OKP"
(gethash "crv" ht) "Ed25519"
(gethash "x" ht) "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
(%send-ok ht)))
;; POST /v1/auth/login
((and (eq method :post) (%path= path "/v1/auth/login"))
(let* ((body (%read-json-body))
(uname (and body (gethash "username" body)))
(pass (and body (gethash "password" body)))
(acct-id (and uname (gethash uname *mock-by-name*)))
(acct (and acct-id (gethash acct-id *mock-accounts*))))
(if (and acct (string= pass (getf acct :password)))
(let* ((token (format nil "mock-token-~A" (gensym "")))
(ht (make-hash-table :test 'equal)))
(setf (gethash token *mock-tokens*) acct-id)
(setf (gethash "token" ht) token
(gethash "expires_at" ht) "2099-01-01T00:00:00Z")
(%send-ok ht))
(%send-error 401 "invalid credentials"))))
;; POST /v1/auth/logout
((and (eq method :post) (%path= path "/v1/auth/logout"))
(let ((token (%bearer-token)))
(when token
(remhash token *mock-tokens*)
(setf (gethash token *mock-revoked*) t)))
(%send-no-content))
;; POST /v1/auth/renew
((and (eq method :post) (%path= path "/v1/auth/renew"))
(let ((acct (%authenticated-account)))
(if acct
(let* ((old-token (%bearer-token))
(acct-id (getf acct :id))
(new-token (format nil "mock-token-~A" (gensym "")))
(ht (make-hash-table :test 'equal)))
;; Revoke old token
(when old-token
(remhash old-token *mock-tokens*)
(setf (gethash old-token *mock-revoked*) t))
(setf (gethash new-token *mock-tokens*) acct-id)
(setf (gethash "token" ht) new-token
(gethash "expires_at" ht) "2099-01-01T00:00:00Z")
(%send-ok ht))
(%send-error 401 "unauthorized"))))
;; POST /v1/token/validate
((and (eq method :post) (%path= path "/v1/token/validate"))
(let* ((body (%read-json-body))
(tok-str (and body (gethash "token" body)))
(ht (make-hash-table :test 'equal)))
(cond
;; Token is present, not revoked, and known
((and tok-str
(not (gethash tok-str *mock-revoked*))
(gethash tok-str *mock-tokens*))
(let* ((acct-id (gethash tok-str *mock-tokens*))
(acct (gethash acct-id *mock-accounts*)))
;; valid = t -> yason encodes as JSON true
(setf (gethash "valid" ht) t
(gethash "sub" ht) acct-id
(gethash "roles" ht) (getf acct :roles)
(gethash "expires_at" ht) "2099-01-01T00:00:00Z")))
(t
;; valid = nil -> yason encodes as JSON false
(setf (gethash "valid" ht) nil)))
(%send-ok ht)))
;; GET /v1/accounts
((and (eq method :get) (%path= path "/v1/accounts"))
(when (%require-admin)
(let ((accts '()))
(maphash (lambda (k v)
(declare (ignore k))
(push (%account->hash v) accts))
*mock-accounts*)
(%send-json 200 (%yason-encode accts)))))
;; POST /v1/accounts
((and (eq method :post) (%path= path "/v1/accounts"))
(when (%require-admin)
(let* ((body (%read-json-body))
(uname (and body (gethash "username" body)))
(atype (and body (gethash "account_type" body)))
(pass (and body (gethash "password" body))))
(if (gethash uname *mock-by-name*)
(%send-error 409 "username already exists")
(let ((id (add-mock-account uname (or pass "nopass") (or atype "user"))))
(%send-json 201 (%yason-encode (%account->hash (gethash id *mock-accounts*)))))))))
;; DELETE /v1/token/:jti
((and (eq method :delete) (%path-prefix-p path "/v1/token/"))
(let ((jti (subseq path (length "/v1/token/"))))
(remhash jti *mock-tokens*)
(setf (gethash jti *mock-revoked*) t))
(%send-no-content))
;; GET /v1/accounts/:id
((and (eq method :get)
(%path-prefix-p path "/v1/accounts/")
;; Make sure it's not /v1/accounts/:id/roles or /pgcreds
(not (cl-ppcre:scan "/" (subseq path (length "/v1/accounts/")))))
(when (%require-admin)
(let* ((id (subseq path (length "/v1/accounts/")))
(acct (gethash id *mock-accounts*)))
(if acct
(%send-ok (%account->hash acct))
(%send-error 404 "account not found")))))
;; GET /v1/accounts/:id/roles
((and (eq method :get)
(cl-ppcre:scan "^/v1/accounts/[^/]+/roles$" path))
(when (%require-admin)
(let* ((parts (cl-ppcre:split "/" path))
(id (nth 3 parts))
(acct (gethash id *mock-accounts*))
(ht (make-hash-table :test 'equal)))
(if acct
(progn
(setf (gethash "roles" ht) (getf acct :roles))
(%send-ok ht))
(%send-error 404 "account not found")))))
;; PUT /v1/accounts/:id/roles
((and (eq method :put)
(cl-ppcre:scan "^/v1/accounts/[^/]+/roles$" path))
(when (%require-admin)
(let* ((parts (cl-ppcre:split "/" path))
(id (nth 3 parts))
(acct (gethash id *mock-accounts*))
(body (%read-json-body))
(roles (and body (gethash "roles" body))))
(if acct
(progn
(setf (getf (gethash id *mock-accounts*) :roles) roles)
(%send-no-content))
(%send-error 404 "account not found")))))
;; PUT /v1/accounts/:id/pgcreds
((and (eq method :put)
(cl-ppcre:scan "^/v1/accounts/[^/]+/pgcreds$" path))
(when (%require-admin)
(let* ((parts (cl-ppcre:split "/" path))
(id (nth 3 parts))
(body (%read-json-body)))
(if (gethash id *mock-accounts*)
(progn
(setf (gethash id *mock-pgcreds*) body)
(%send-no-content))
(%send-error 404 "account not found")))))
;; GET /v1/accounts/:id/pgcreds
((and (eq method :get)
(cl-ppcre:scan "^/v1/accounts/[^/]+/pgcreds$" path))
(when (%require-admin)
(let* ((parts (cl-ppcre:split "/" path))
(id (nth 3 parts))
(creds (gethash id *mock-pgcreds*)))
(if creds
(%send-ok creds)
(%send-error 404 "no pgcreds for account")))))
;; PATCH /v1/accounts/:id
((and (eq method :patch)
(%path-prefix-p path "/v1/accounts/")
(not (cl-ppcre:scan "/" (subseq path (length "/v1/accounts/")))))
(when (%require-admin)
(let* ((id (subseq path (length "/v1/accounts/")))
(acct (gethash id *mock-accounts*))
(body (%read-json-body)))
(if acct
(progn
(when (and body (gethash "status" body))
(setf (getf (gethash id *mock-accounts*) :status)
(gethash "status" body)))
(%send-ok (%account->hash (gethash id *mock-accounts*))))
(%send-error 404 "account not found")))))
;; DELETE /v1/accounts/:id
((and (eq method :delete)
(%path-prefix-p path "/v1/accounts/")
(not (cl-ppcre:scan "/" (subseq path (length "/v1/accounts/")))))
(when (%require-admin)
(let* ((id (subseq path (length "/v1/accounts/")))
(acct (gethash id *mock-accounts*)))
(if acct
(progn
(remhash id *mock-accounts*)
(maphash (lambda (k v)
(when (string= v id)
(remhash k *mock-by-name*)))
*mock-by-name*)
(%send-no-content))
(%send-error 404 "account not found")))))
;; POST /v1/token/issue
((and (eq method :post) (%path= path "/v1/token/issue"))
(when (%require-admin)
(let* ((body (%read-json-body))
(acct-id (and body (gethash "account_id" body))))
(if (and acct-id (gethash acct-id *mock-accounts*))
(let* ((token (%issue-mock-token acct-id))
(ht (make-hash-table :test 'equal)))
(setf (gethash "token" ht) token
(gethash "expires_at" ht) "2099-01-01T00:00:00Z")
(%send-ok ht))
(%send-error 404 "account not found")))))
;; Catch-all
(t
(%send-error 404 (format nil "not found: ~A ~A" method path))))))
;;;; -----------------------------------------------------------------------
;;;; Start/stop
;;;; -----------------------------------------------------------------------
(defun start-mock-server (&key (port 0))
"Start the mock MCIAS server on PORT (0 = OS-assigned).
Returns the actual port bound."
(reset-mock-state!)
;; Seed an admin account.
(add-mock-account "admin" "adminpass" "admin" "admin")
(let ((acceptor (make-instance 'mock-dispatcher
:port port
:access-log-destination nil
:message-log-destination nil)))
(hunchentoot:start acceptor)
(setf *mock-server* acceptor)
(hunchentoot:acceptor-port acceptor)))
(defun stop-mock-server ()
"Stop the running mock server."
(when *mock-server*
(hunchentoot:stop *mock-server*)
(setf *mock-server* nil)))

View File

@@ -0,0 +1,8 @@
;;;; tests/package.lisp
;;; We do NOT :use #:fiveam to avoid importing fiveam symbols into our
;;; package (which causes SBCL package-lock errors on some versions).
;;; Instead we prefix all fiveam calls with fiveam:.
(defpackage #:mcias-client-tests
(:use #:cl #:mcias-client)
(:export #:run-all-tests))

91
clients/python/README.md Normal file
View File

@@ -0,0 +1,91 @@
# mcias-client (Python)
Python client library for the [MCIAS](../../README.md) identity and access management API.
## Requirements
- Python 3.11+
- `httpx >= 0.27`
## Installation
```sh
pip install .
# or in development mode:
pip install -e ".[dev]"
```
## Quick Start
```python
from mcias_client import Client
# Connect to the MCIAS server.
with Client("https://auth.example.com") as client:
# Authenticate.
token, expires_at = client.login("alice", "s3cret")
print(f"token expires at {expires_at}")
# The token is stored in the client automatically.
accounts = client.list_accounts()
# Revoke the token when done (also called automatically on context exit).
client.logout()
```
## Custom CA Certificate
```python
client = Client(
"https://auth.example.com",
ca_cert_path="/etc/mcias/ca.pem",
)
```
## Error Handling
All methods raise typed exceptions on error:
```python
from mcias_client import (
MciasAuthError,
MciasForbiddenError,
MciasNotFoundError,
MciasInputError,
MciasConflictError,
MciasServerError,
)
try:
client.login("alice", "wrongpass")
except MciasAuthError as e:
print(f"auth failed ({e.status_code}): {e.message}")
except MciasForbiddenError as e:
print(f"forbidden: {e.message}")
except MciasNotFoundError as e:
print(f"not found: {e.message}")
except MciasInputError as e:
print(f"bad input: {e.message}")
except MciasConflictError as e:
print(f"conflict: {e.message}")
except MciasServerError as e:
print(f"server error {e.status_code}: {e.message}")
```
All exception types are subclasses of `MciasError`, which has attributes:
- `status_code: int` — HTTP status code
- `message: str` — server error message
## Thread Safety
`Client` is **not** thread-safe. Each thread should use its own `Client`
instance.
## Running Tests
```sh
pip install -e ".[dev]"
pytest tests/ -q
mypy mcias_client/ tests/
ruff check mcias_client/ tests/
```

View File

@@ -0,0 +1,12 @@
Metadata-Version: 2.4
Name: mcias-client
Version: 0.1.0
Summary: Python client library for the MCIAS identity and access management API
License: MIT
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"

View File

@@ -0,0 +1,13 @@
README.md
pyproject.toml
mcias_client/__init__.py
mcias_client/_client.py
mcias_client/_errors.py
mcias_client/_models.py
mcias_client/py.typed
mcias_client.egg-info/PKG-INFO
mcias_client.egg-info/SOURCES.txt
mcias_client.egg-info/dependency_links.txt
mcias_client.egg-info/requires.txt
mcias_client.egg-info/top_level.txt
tests/test_client.py

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,7 @@
httpx>=0.27
[dev]
pytest>=8
respx>=0.21
mypy>=1.10
ruff>=0.4

View File

@@ -0,0 +1 @@
mcias_client

View File

@@ -0,0 +1,27 @@
"""MCIAS Python client library."""
from ._client import Client
from ._errors import (
MciasAuthError,
MciasConflictError,
MciasError,
MciasForbiddenError,
MciasInputError,
MciasNotFoundError,
MciasServerError,
)
from ._models import Account, PGCreds, PublicKey, TokenClaims
__all__ = [
"Client",
"MciasError",
"MciasAuthError",
"MciasForbiddenError",
"MciasNotFoundError",
"MciasInputError",
"MciasConflictError",
"MciasServerError",
"Account",
"PublicKey",
"TokenClaims",
"PGCreds",
]

View File

@@ -0,0 +1,216 @@
"""Synchronous HTTP client for the MCIAS API."""
from __future__ import annotations
import ssl
from types import TracebackType
from typing import Any
import httpx
from ._errors import raise_for_status
from ._models import Account, PGCreds, PublicKey, TokenClaims
class Client:
"""Synchronous MCIAS API client backed by httpx."""
def __init__(
self,
server_url: str,
*,
ca_cert_path: str | None = None,
token: str | None = None,
timeout: float = 30.0,
) -> None:
self._base_url = server_url.rstrip("/")
self.token = token
ssl_context: ssl.SSLContext | bool
if ca_cert_path is not None:
ssl_context = ssl.create_default_context(cafile=ca_cert_path)
else:
ssl_context = True # use default SSL verification
self._http = httpx.Client(
verify=ssl_context,
timeout=timeout,
)
def __enter__(self) -> Client:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
def close(self) -> None:
"""Close the underlying HTTP client."""
self._http.close()
def _request(
self,
method: str,
path: str,
*,
json: dict[str, Any] | None = None,
expected_status: int | None = None,
) -> dict[str, Any] | None:
"""Send an HTTP request and return the parsed JSON body.
Returns None for 204 No Content responses.
Raises the appropriate MciasError subclass on 4xx/5xx.
"""
url = f"{self._base_url}{path}"
headers: dict[str, str] = {}
if self.token is not None:
headers["Authorization"] = f"Bearer {self.token}"
response = self._http.request(method, url, json=json, headers=headers)
status = response.status_code
if expected_status is not None:
success_codes = {expected_status}
else:
success_codes = {200, 201, 204}
if status not in success_codes and status >= 400:
try:
body = response.json()
message = str(body.get("error", response.text))
except Exception:
message = response.text
raise_for_status(status, message)
if status == 204 or not response.content:
return None
return response.json() # type: ignore[no-any-return]
def health(self) -> None:
"""GET /v1/health — liveness check."""
self._request("GET", "/v1/health")
def get_public_key(self) -> PublicKey:
"""GET /v1/keys/public — retrieve the server's Ed25519 public key."""
data = self._request("GET", "/v1/keys/public")
assert data is not None
return PublicKey.from_dict(data)
def login(
self,
username: str,
password: str,
totp_code: str | None = None,
) -> tuple[str, str]:
"""POST /v1/auth/login — authenticate and obtain a JWT.
Returns (token, expires_at). Stores the token on self.token.
"""
payload: dict[str, Any] = {
"username": username,
"password": password,
}
if totp_code is not None:
payload["totp_code"] = totp_code
data = self._request("POST", "/v1/auth/login", json=payload)
assert data is not None
token = str(data["token"])
expires_at = str(data["expires_at"])
self.token = token
return token, expires_at
def logout(self) -> None:
"""POST /v1/auth/logout — invalidate the current token."""
self._request("POST", "/v1/auth/logout")
self.token = None
def renew_token(self) -> tuple[str, str]:
"""POST /v1/auth/renew — exchange current token for a fresh one.
Returns (token, expires_at). Updates self.token.
"""
data = self._request("POST", "/v1/auth/renew")
assert data is not None
token = str(data["token"])
expires_at = str(data["expires_at"])
self.token = token
return token, expires_at
def validate_token(self, token: str) -> TokenClaims:
"""POST /v1/token/validate — check whether a token is valid."""
data = self._request("POST", "/v1/token/validate", json={"token": token})
assert data is not None
return TokenClaims.from_dict(data)
def create_account(
self,
username: str,
account_type: str,
*,
password: str | None = None,
) -> Account:
"""POST /v1/accounts — create a new account."""
payload: dict[str, Any] = {
"username": username,
"account_type": account_type,
}
if password is not None:
payload["password"] = password
data = self._request("POST", "/v1/accounts", json=payload)
assert data is not None
return Account.from_dict(data)
def list_accounts(self) -> list[Account]:
"""GET /v1/accounts — list all accounts."""
data = self._request("GET", "/v1/accounts")
assert data is not None
accounts_raw = data.get("accounts") or []
return [Account.from_dict(a) for a in accounts_raw]
def get_account(self, account_id: str) -> Account:
"""GET /v1/accounts/{id} — retrieve a single account."""
data = self._request("GET", f"/v1/accounts/{account_id}")
assert data is not None
return Account.from_dict(data)
def update_account(
self,
account_id: str,
*,
status: str | None = None,
) -> Account:
"""PATCH /v1/accounts/{id} — update account fields."""
payload: dict[str, Any] = {}
if status is not None:
payload["status"] = status
data = self._request("PATCH", f"/v1/accounts/{account_id}", json=payload)
assert data is not None
return Account.from_dict(data)
def delete_account(self, account_id: str) -> None:
"""DELETE /v1/accounts/{id} — permanently remove an account."""
self._request("DELETE", f"/v1/accounts/{account_id}")
def get_roles(self, account_id: str) -> list[str]:
"""GET /v1/accounts/{id}/roles — list roles for an account."""
data = self._request("GET", f"/v1/accounts/{account_id}/roles")
assert data is not None
roles_raw = data.get("roles") or []
return [str(r) for r in roles_raw]
def set_roles(self, account_id: str, roles: list[str]) -> None:
"""PUT /v1/accounts/{id}/roles — replace the full role set."""
self._request(
"PUT",
f"/v1/accounts/{account_id}/roles",
json={"roles": roles},
)
def issue_service_token(self, account_id: str) -> tuple[str, str]:
"""POST /v1/accounts/{id}/token — issue a long-lived service token.
Returns (token, expires_at).
"""
data = self._request("POST", f"/v1/accounts/{account_id}/token")
assert data is not None
return str(data["token"]), str(data["expires_at"])
def revoke_token(self, jti: str) -> None:
"""DELETE /v1/token/{jti} — revoke a token by JTI."""
self._request("DELETE", f"/v1/token/{jti}")
def get_pg_creds(self, account_id: str) -> PGCreds:
"""GET /v1/accounts/{id}/pgcreds — retrieve Postgres credentials."""
data = self._request("GET", f"/v1/accounts/{account_id}/pgcreds")
assert data is not None
return PGCreds.from_dict(data)
def set_pg_creds(
self,
account_id: str,
host: str,
port: int,
database: str,
username: str,
password: str,
) -> None:
"""PUT /v1/accounts/{id}/pgcreds — store or replace Postgres credentials."""
payload: dict[str, Any] = {
"host": host,
"port": port,
"database": database,
"username": username,
"password": password,
}
self._request("PUT", f"/v1/accounts/{account_id}/pgcreds", json=payload)

View File

@@ -0,0 +1,30 @@
"""Typed exception hierarchy for MCIAS client errors."""
class MciasError(Exception):
"""Base exception for all MCIAS API errors."""
def __init__(self, status_code: int, message: str) -> None:
super().__init__(f"HTTP {status_code}: {message}")
self.status_code = status_code
self.message = message
class MciasAuthError(MciasError):
"""401 Unauthorized — token missing, invalid, or expired."""
class MciasForbiddenError(MciasError):
"""403 Forbidden — insufficient role."""
class MciasNotFoundError(MciasError):
"""404 Not Found — resource does not exist."""
class MciasInputError(MciasError):
"""400 Bad Request — malformed request."""
class MciasConflictError(MciasError):
"""409 Conflict — e.g. duplicate username."""
class MciasServerError(MciasError):
"""5xx — unexpected server error."""
def raise_for_status(status_code: int, message: str) -> None:
"""Raise the appropriate MciasError subclass for the given status code."""
exc_map = {
400: MciasInputError,
401: MciasAuthError,
403: MciasForbiddenError,
404: MciasNotFoundError,
409: MciasConflictError,
}
cls = exc_map.get(status_code, MciasServerError)
raise cls(status_code, message)

View File

@@ -0,0 +1,76 @@
"""Data models for MCIAS API responses."""
from dataclasses import dataclass, field
from typing import cast
@dataclass
class Account:
"""A user or service account."""
id: str
username: str
account_type: str
status: str
created_at: str
updated_at: str
totp_enabled: bool = False
@classmethod
def from_dict(cls, d: dict[str, object]) -> "Account":
return cls(
id=str(d["id"]),
username=str(d["username"]),
account_type=str(d["account_type"]),
status=str(d["status"]),
created_at=str(d["created_at"]),
updated_at=str(d["updated_at"]),
totp_enabled=bool(d.get("totp_enabled", False)),
)
@dataclass
class PublicKey:
"""Ed25519 public key in JWK format."""
kty: str
crv: str
x: str
use: str = ""
alg: str = ""
@classmethod
def from_dict(cls, d: dict[str, object]) -> "PublicKey":
return cls(
kty=str(d["kty"]),
crv=str(d["crv"]),
x=str(d["x"]),
use=str(d.get("use", "")),
alg=str(d.get("alg", "")),
)
@dataclass
class TokenClaims:
"""Claims from a validated token."""
valid: bool
sub: str = ""
roles: list[str] = field(default_factory=list)
expires_at: str = ""
@classmethod
def from_dict(cls, d: dict[str, object]) -> "TokenClaims":
roles_raw = cast(list[object], d.get("roles") or [])
return cls(
valid=bool(d.get("valid", False)),
sub=str(d.get("sub", "")),
roles=[str(r) for r in roles_raw],
expires_at=str(d.get("expires_at", "")),
)
@dataclass
class PGCreds:
"""Postgres connection credentials."""
host: str
port: int
database: str
username: str
password: str
@classmethod
def from_dict(cls, d: dict[str, object]) -> "PGCreds":
return cls(
host=str(d["host"]),
port=int(cast(int, d["port"])),
database=str(d["database"]),
username=str(d["username"]),
password=str(d["password"]),
)

View File

View File

@@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "mcias-client"
version = "0.1.0"
description = "Python client library for the MCIAS identity and access management API"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"httpx>=0.27",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"respx>=0.21",
"mypy>=1.10",
"ruff>=0.4",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["mcias_client*"]
[tool.mypy]
strict = true
python_version = "3.11"
[tool.ruff]
target-version = "py311"
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP"]

View File

View File

@@ -0,0 +1,320 @@
"""Tests for the MCIAS Python client using respx to mock httpx."""
from __future__ import annotations
import httpx
import pytest
import respx
from mcias_client import (
Client,
MciasAuthError,
MciasConflictError,
MciasError,
MciasForbiddenError,
MciasInputError,
MciasNotFoundError,
MciasServerError,
)
from mcias_client._models import Account, PGCreds, PublicKey, TokenClaims
BASE_URL = "https://auth.example.com"
SAMPLE_ACCOUNT: dict[str, object] = {
"id": "acc-001",
"username": "alice",
"account_type": "user",
"status": "active",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"totp_enabled": False,
}
SAMPLE_PK: dict[str, object] = {
"kty": "OKP",
"crv": "Ed25519",
"x": "base64urlpublickey",
"use": "sig",
"alg": "EdDSA",
}
@pytest.fixture
def client() -> Client:
return Client(BASE_URL)
@pytest.fixture
def admin_client() -> Client:
return Client(BASE_URL, token="admin-token")
def test_client_init() -> None:
c = Client(BASE_URL)
assert c.token is None
assert c._base_url == BASE_URL
c.close()
def test_client_strips_trailing_slash() -> None:
c = Client(BASE_URL + "/")
assert c._base_url == BASE_URL
c.close()
def test_client_init_with_token() -> None:
c = Client(BASE_URL, token="mytoken")
assert c.token == "mytoken"
c.close()
@respx.mock
def test_health_ok(client: Client) -> None:
respx.get(f"{BASE_URL}/v1/health").mock(return_value=httpx.Response(200))
client.health() # should not raise
@respx.mock
def test_health_server_error(client: Client) -> None:
respx.get(f"{BASE_URL}/v1/health").mock(
return_value=httpx.Response(503, json={"error": "service unavailable"})
)
with pytest.raises(MciasServerError) as exc_info:
client.health()
assert exc_info.value.status_code == 503
@respx.mock
def test_get_public_key(client: Client) -> None:
respx.get(f"{BASE_URL}/v1/keys/public").mock(
return_value=httpx.Response(200, json=SAMPLE_PK)
)
pk = client.get_public_key()
assert isinstance(pk, PublicKey)
assert pk.kty == "OKP"
assert pk.crv == "Ed25519"
assert pk.alg == "EdDSA"
@respx.mock
def test_login_success(client: Client) -> None:
respx.post(f"{BASE_URL}/v1/auth/login").mock(
return_value=httpx.Response(
200,
json={"token": "jwt-token-abc", "expires_at": "2099-01-01T00:00:00Z"},
)
)
token, expires_at = client.login("alice", "s3cr3t")
assert token == "jwt-token-abc"
assert expires_at == "2099-01-01T00:00:00Z"
assert client.token == "jwt-token-abc"
@respx.mock
def test_login_unauthorized(client: Client) -> None:
respx.post(f"{BASE_URL}/v1/auth/login").mock(
return_value=httpx.Response(
401, json={"error": "invalid credentials"}
)
)
with pytest.raises(MciasAuthError) as exc_info:
client.login("alice", "wrong")
assert exc_info.value.status_code == 401
@respx.mock
def test_logout_clears_token(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/auth/logout").mock(
return_value=httpx.Response(204)
)
assert admin_client.token == "admin-token"
admin_client.logout()
assert admin_client.token is None
@respx.mock
def test_renew_token(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/auth/renew").mock(
return_value=httpx.Response(
200,
json={"token": "new-jwt-token", "expires_at": "2099-06-01T00:00:00Z"},
)
)
token, expires_at = admin_client.renew_token()
assert token == "new-jwt-token"
assert expires_at == "2099-06-01T00:00:00Z"
assert admin_client.token == "new-jwt-token"
@respx.mock
def test_validate_token_valid(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/token/validate").mock(
return_value=httpx.Response(
200,
json={
"valid": True,
"sub": "acc-001",
"roles": ["admin"],
"expires_at": "2099-01-01T00:00:00Z",
},
)
)
claims = admin_client.validate_token("some-token")
assert isinstance(claims, TokenClaims)
assert claims.valid is True
assert claims.sub == "acc-001"
assert claims.roles == ["admin"]
@respx.mock
def test_validate_token_invalid(admin_client: Client) -> None:
"""valid=False in the response body is NOT an exception — just a falsy claim."""
respx.post(f"{BASE_URL}/v1/token/validate").mock(
return_value=httpx.Response(
200,
json={"valid": False, "sub": "", "roles": []},
)
)
claims = admin_client.validate_token("expired-token")
assert claims.valid is False
@respx.mock
def test_create_account(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/accounts").mock(
return_value=httpx.Response(201, json=SAMPLE_ACCOUNT)
)
acc = admin_client.create_account("alice", "user", password="pass123")
assert isinstance(acc, Account)
assert acc.id == "acc-001"
assert acc.username == "alice"
@respx.mock
def test_create_account_conflict(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/accounts").mock(
return_value=httpx.Response(409, json={"error": "username already exists"})
)
with pytest.raises(MciasConflictError) as exc_info:
admin_client.create_account("alice", "user")
assert exc_info.value.status_code == 409
@respx.mock
def test_list_accounts(admin_client: Client) -> None:
second = {**SAMPLE_ACCOUNT, "id": "acc-002"}
respx.get(f"{BASE_URL}/v1/accounts").mock(
return_value=httpx.Response(
200, json={"accounts": [SAMPLE_ACCOUNT, second]}
)
)
accounts = admin_client.list_accounts()
assert len(accounts) == 2
assert all(isinstance(a, Account) for a in accounts)
@respx.mock
def test_get_account(admin_client: Client) -> None:
respx.get(f"{BASE_URL}/v1/accounts/acc-001").mock(
return_value=httpx.Response(200, json=SAMPLE_ACCOUNT)
)
acc = admin_client.get_account("acc-001")
assert acc.id == "acc-001"
@respx.mock
def test_update_account(admin_client: Client) -> None:
updated = {**SAMPLE_ACCOUNT, "status": "suspended"}
respx.patch(f"{BASE_URL}/v1/accounts/acc-001").mock(
return_value=httpx.Response(200, json=updated)
)
acc = admin_client.update_account("acc-001", status="suspended")
assert acc.status == "suspended"
@respx.mock
def test_delete_account(admin_client: Client) -> None:
respx.delete(f"{BASE_URL}/v1/accounts/acc-001").mock(
return_value=httpx.Response(204)
)
admin_client.delete_account("acc-001") # should not raise
@respx.mock
def test_get_roles(admin_client: Client) -> None:
respx.get(f"{BASE_URL}/v1/accounts/acc-001/roles").mock(
return_value=httpx.Response(200, json={"roles": ["admin", "viewer"]})
)
roles = admin_client.get_roles("acc-001")
assert roles == ["admin", "viewer"]
@respx.mock
def test_set_roles(admin_client: Client) -> None:
respx.put(f"{BASE_URL}/v1/accounts/acc-001/roles").mock(
return_value=httpx.Response(204)
)
admin_client.set_roles("acc-001", ["viewer"]) # should not raise
@respx.mock
def test_issue_service_token(admin_client: Client) -> None:
respx.post(f"{BASE_URL}/v1/accounts/acc-001/token").mock(
return_value=httpx.Response(
200,
json={"token": "svc-token-xyz", "expires_at": "2099-12-31T00:00:00Z"},
)
)
token, expires_at = admin_client.issue_service_token("acc-001")
assert token == "svc-token-xyz"
assert expires_at == "2099-12-31T00:00:00Z"
@respx.mock
def test_revoke_token(admin_client: Client) -> None:
jti = "some-jti-uuid"
respx.delete(f"{BASE_URL}/v1/token/{jti}").mock(
return_value=httpx.Response(204)
)
admin_client.revoke_token(jti) # should not raise
SAMPLE_PG_CREDS: dict[str, object] = {
"host": "db.example.com",
"port": 5432,
"database": "myapp",
"username": "appuser",
"password": "s3cr3t",
}
@respx.mock
def test_get_pg_creds(admin_client: Client) -> None:
respx.get(f"{BASE_URL}/v1/accounts/acc-001/pgcreds").mock(
return_value=httpx.Response(200, json=SAMPLE_PG_CREDS)
)
creds = admin_client.get_pg_creds("acc-001")
assert isinstance(creds, PGCreds)
assert creds.host == "db.example.com"
assert creds.port == 5432
assert creds.database == "myapp"
@respx.mock
def test_set_pg_creds(admin_client: Client) -> None:
respx.put(f"{BASE_URL}/v1/accounts/acc-001/pgcreds").mock(
return_value=httpx.Response(204)
)
admin_client.set_pg_creds(
"acc-001",
host="db.example.com",
port=5432,
database="myapp",
username="appuser",
password="s3cr3t",
) # should not raise
@pytest.mark.parametrize(
("status_code", "exc_class"),
[
(400, MciasInputError),
(401, MciasAuthError),
(403, MciasForbiddenError),
(404, MciasNotFoundError),
(409, MciasConflictError),
(500, MciasServerError),
],
)
@respx.mock
def test_error_types(
client: Client,
status_code: int,
exc_class: type,
) -> None:
respx.get(f"{BASE_URL}/v1/health").mock(
return_value=httpx.Response(
status_code, json={"error": "test error"}
)
)
with pytest.raises(exc_class) as exc_info:
client.health()
err = exc_info.value
assert isinstance(err, MciasError)
assert err.status_code == status_code
@respx.mock
def test_context_manager() -> None:
respx.get(f"{BASE_URL}/v1/health").mock(return_value=httpx.Response(200))
with Client(BASE_URL) as c:
c.health()
assert c._http.is_closed
@respx.mock
def test_integration_login_validate_logout() -> None:
"""Full flow: login, validate the issued token, then logout."""
login_resp = httpx.Response(
200,
json={"token": "flow-token-abc", "expires_at": "2099-01-01T00:00:00Z"},
)
validate_resp = httpx.Response(
200,
json={
"valid": True,
"sub": "acc-001",
"roles": ["admin"],
"expires_at": "2099-01-01T00:00:00Z",
},
)
logout_resp = httpx.Response(204)
respx.post(f"{BASE_URL}/v1/auth/login").mock(return_value=login_resp)
respx.post(f"{BASE_URL}/v1/token/validate").mock(return_value=validate_resp)
respx.post(f"{BASE_URL}/v1/auth/logout").mock(return_value=logout_resp)
with Client(BASE_URL) as c:
token, _ = c.login("alice", "password")
assert token == "flow-token-abc"
assert c.token == "flow-token-abc"
claims = c.validate_token(token)
assert claims.valid is True
assert "admin" in claims.roles
c.logout()
assert c.token is None

1619
clients/rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
clients/rust/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "mcias-client"
version = "0.1.0"
edition = "2021"
description = "Rust client library for the MCIAS identity and access management API"
license = "MIT"
[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
thiserror = "2"
[dev-dependencies]
wiremock = "0.6"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }

88
clients/rust/README.md Normal file
View File

@@ -0,0 +1,88 @@
# mcias-client (Rust)
Async Rust client library for the [MCIAS](../../README.md) identity and access management API.
## Requirements
- Rust 2021 edition (stable toolchain)
- Tokio async runtime
## Installation
Add to `Cargo.toml`:
```toml
[dependencies]
mcias-client = { path = "path/to/clients/rust" }
```
## Quick Start
```rust
use mcias_client::{Client, ClientOptions};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new(
"https://auth.example.com".to_string(),
ClientOptions::default(),
)?;
// Authenticate.
let (token, expires_at) = client.login("alice", "s3cret", None).await?;
println!("token expires at {expires_at}");
// The token is stored in the client automatically.
let accounts = client.list_accounts().await?;
// Revoke the token when done.
client.logout().await?;
Ok(())
}
```
## Custom CA Certificate
```rust
let ca_pem = std::fs::read("/etc/mcias/ca.pem")?;
let client = Client::new(
"https://auth.example.com".to_string(),
ClientOptions {
ca_cert_pem: Some(ca_pem),
token: None,
},
)?;
```
## Error Handling
All methods return `Result<_, MciasError>`:
```rust
use mcias_client::MciasError;
match client.login("alice", "wrongpass", None).await {
Err(MciasError::Auth { message }) => eprintln!("auth failed: {message}"),
Err(MciasError::Forbidden { message }) => eprintln!("forbidden: {message}"),
Err(MciasError::NotFound { message }) => eprintln!("not found: {message}"),
Err(MciasError::InvalidInput { message }) => eprintln!("bad input: {message}"),
Err(MciasError::Conflict { message }) => eprintln!("conflict: {message}"),
Err(MciasError::Server { status, message }) => eprintln!("server error {status}: {message}"),
Err(MciasError::Transport(e)) => eprintln!("network error: {e}"),
Err(MciasError::Decode(e)) => eprintln!("parse error: {e}"),
Ok((token, _)) => println!("ok: {token}"),
}
```
## Thread Safety
`Client` is `Send + Sync`. The internal token is wrapped in
`Arc<RwLock<Option<String>>>` for safe concurrent access.
## Running Tests
```sh
cargo test
cargo clippy -- -D warnings
```

514
clients/rust/src/lib.rs Normal file
View File

@@ -0,0 +1,514 @@
//! # mcias-client
//!
//! Async Rust client for the MCIAS (Metacircular Identity and Access System)
//! REST API.
//!
//! ## Usage
//!
//! ```rust,no_run
//! use mcias_client::{Client, ClientOptions};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let client = Client::new("https://auth.example.com", ClientOptions::default())?;
//!
//! let (token, expires_at) = client.login("alice", "s3cret", None).await?;
//! println!("Logged in, token expires at {expires_at}");
//!
//! client.logout().await?;
//! Ok(())
//! }
//! ```
//!
//! ## Thread Safety
//!
//! [`Client`] is `Clone + Send + Sync`. The internally stored bearer token is
//! protected by an `Arc<tokio::sync::RwLock<...>>` so concurrent async tasks
//! may share a single client safely.
use std::sync::Arc;
use reqwest::{header, Certificate, StatusCode};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
// ---- Error types ----
/// All errors returned by the MCIAS client.
#[derive(Debug, thiserror::Error)]
pub enum MciasError {
/// HTTP 401 — authentication required or credentials invalid.
#[error("authentication error: {0}")]
Auth(String),
/// HTTP 403 — caller lacks required role.
#[error("permission denied: {0}")]
Forbidden(String),
/// HTTP 404 — requested resource does not exist.
#[error("not found: {0}")]
NotFound(String),
/// HTTP 400 — the request payload was invalid.
#[error("invalid input: {0}")]
InvalidInput(String),
/// HTTP 409 — resource conflict (e.g. duplicate username).
#[error("conflict: {0}")]
Conflict(String),
/// HTTP 5xx — the server returned an internal error.
#[error("server error ({status}): {message}")]
Server { status: u16, message: String },
/// Transport-level error (DNS failure, connection refused, timeout, etc.).
#[error("transport error: {0}")]
Transport(#[from] reqwest::Error),
/// Response body could not be decoded.
#[error("decode error: {0}")]
Decode(String),
}
// ---- Data types ----
/// Account information returned by the server.
#[derive(Debug, Clone, Deserialize)]
pub struct Account {
pub id: String,
pub username: String,
pub account_type: String,
pub status: String,
pub created_at: String,
pub updated_at: String,
pub totp_enabled: bool,
}
/// Result of a token validation request.
#[derive(Debug, Clone, Deserialize)]
pub struct TokenClaims {
pub valid: bool,
#[serde(default)]
pub sub: String,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub expires_at: String,
}
/// The server's Ed25519 public key in JWK format.
#[derive(Debug, Clone, Deserialize)]
pub struct PublicKey {
pub kty: String,
pub crv: String,
pub x: String,
}
/// Postgres credentials returned by the server.
#[derive(Debug, Clone, Deserialize)]
pub struct PgCreds {
pub host: String,
pub port: u16,
pub database: String,
pub username: String,
pub password: String,
}
// ---- Internal request/response types ----
#[derive(Serialize)]
struct LoginRequest<'a> {
username: &'a str,
password: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
totp_code: Option<&'a str>,
}
#[derive(Deserialize)]
struct TokenResponse {
token: String,
expires_at: String,
}
#[derive(Deserialize)]
struct ErrorResponse {
#[serde(default)]
error: String,
}
// ---- Client options ----
/// Configuration options for the MCIAS client.
#[derive(Debug, Default, Clone)]
pub struct ClientOptions {
/// Optional PEM-encoded CA certificate for TLS verification.
/// Use when connecting to a server with a self-signed or private-CA cert.
pub ca_cert_pem: Option<Vec<u8>>,
/// Optional pre-existing bearer token.
pub token: Option<String>,
}
// ---- Client ----
/// Async MCIAS REST API client.
///
/// `Client` is cheaply cloneable — the internal HTTP client and token storage
/// are reference-counted. All clones share the same token.
#[derive(Clone)]
pub struct Client {
base_url: String,
http: reqwest::Client,
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
token: Arc<RwLock<Option<String>>>,
}
impl Client {
/// Create a new client.
///
/// `base_url` must be an HTTPS URL (e.g. `"https://auth.example.com"`).
/// TLS 1.2+ is enforced by the underlying `reqwest` / `rustls` stack.
pub fn new(base_url: &str, opts: ClientOptions) -> Result<Self, MciasError> {
let mut builder = reqwest::ClientBuilder::new()
// Security: enforce TLS 1.2+ minimum.
.min_tls_version(reqwest::tls::Version::TLS_1_2)
.use_rustls_tls();
if let Some(pem) = opts.ca_cert_pem {
let cert = Certificate::from_pem(&pem)
.map_err(|e| MciasError::Decode(format!("parse CA cert: {e}")))?;
builder = builder.add_root_certificate(cert);
}
let http = builder.build()?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_owned(),
http,
token: Arc::new(RwLock::new(opts.token)),
})
}
/// Return the currently stored bearer token, if any.
pub async fn token(&self) -> Option<String> {
self.token.read().await.clone()
}
/// Replace the stored bearer token.
pub async fn set_token(&self, tok: Option<String>) {
*self.token.write().await = tok;
}
// ---- Authentication ----
/// Login with username and password. On success stores the returned token
/// and returns `(token, expires_at)`.
///
/// `totp_code` may be `None` when TOTP is not enrolled.
pub async fn login(
&self,
username: &str,
password: &str,
totp_code: Option<&str>,
) -> Result<(String, String), MciasError> {
let body = LoginRequest {
username,
password,
totp_code,
};
let resp: TokenResponse = self.post("/v1/auth/login", &body).await?;
*self.token.write().await = Some(resp.token.clone());
Ok((resp.token, resp.expires_at))
}
/// Logout — revoke the current token on the server. Clears the stored token.
pub async fn logout(&self) -> Result<(), MciasError> {
self.post_empty("/v1/auth/logout").await?;
*self.token.write().await = None;
Ok(())
}
/// Renew the current token. The old token is revoked server-side; the new
/// token is stored and returned as `(token, expires_at)`.
pub async fn renew_token(&self) -> Result<(String, String), MciasError> {
let resp: TokenResponse = self.post("/v1/auth/renew", &serde_json::json!({})).await?;
*self.token.write().await = Some(resp.token.clone());
Ok((resp.token, resp.expires_at))
}
/// Validate a token. Returns [`TokenClaims`] with `valid: false` (no error)
/// if the token is invalid or revoked.
pub async fn validate_token(&self, token: &str) -> Result<TokenClaims, MciasError> {
let body = serde_json::json!({ "token": token });
self.post("/v1/token/validate", &body).await
}
// ---- Server information ----
/// Call the health endpoint. Returns `Ok(())` on HTTP 200.
pub async fn health(&self) -> Result<(), MciasError> {
self.get_empty("/v1/health").await
}
/// Return the server's Ed25519 public key in JWK format.
pub async fn get_public_key(&self) -> Result<PublicKey, MciasError> {
self.get("/v1/keys/public").await
}
// ---- Account management (admin only) ----
/// Create a new account. `account_type` must be `"human"` or `"system"`.
pub async fn create_account(
&self,
username: &str,
password: Option<&str>,
account_type: &str,
) -> Result<Account, MciasError> {
let mut body = serde_json::json!({
"username": username,
"account_type": account_type,
});
if let Some(pw) = password {
body["password"] = serde_json::Value::String(pw.to_owned());
}
self.post_expect_status("/v1/accounts", &body, StatusCode::CREATED)
.await
}
/// List all accounts.
pub async fn list_accounts(&self) -> Result<Vec<Account>, MciasError> {
self.get("/v1/accounts").await
}
/// Get a single account by UUID.
pub async fn get_account(&self, id: &str) -> Result<Account, MciasError> {
self.get(&format!("/v1/accounts/{id}")).await
}
/// Update an account's status. Allowed values: `"active"`, `"inactive"`.
pub async fn update_account(&self, id: &str, status: &str) -> Result<Account, MciasError> {
let body = serde_json::json!({ "status": status });
self.patch(&format!("/v1/accounts/{id}"), &body).await
}
/// Soft-delete an account and revoke all its tokens.
pub async fn delete_account(&self, id: &str) -> Result<(), MciasError> {
self.delete(&format!("/v1/accounts/{id}")).await
}
// ---- Role management (admin only) ----
/// Get all roles assigned to an account.
pub async fn get_roles(&self, account_id: &str) -> Result<Vec<String>, MciasError> {
self.get(&format!("/v1/accounts/{account_id}/roles")).await
}
/// Replace the complete role set for an account.
pub async fn set_roles(&self, account_id: &str, roles: &[&str]) -> Result<(), MciasError> {
let url = format!("/v1/accounts/{account_id}/roles");
self.put_no_content(&url, roles).await
}
// ---- Token management (admin only) ----
/// Issue a long-lived token for a system account.
pub async fn issue_service_token(
&self,
account_id: &str,
) -> Result<(String, String), MciasError> {
let body = serde_json::json!({ "account_id": account_id });
let resp: TokenResponse = self.post("/v1/token/issue", &body).await?;
Ok((resp.token, resp.expires_at))
}
/// Revoke a token by JTI.
pub async fn revoke_token(&self, jti: &str) -> Result<(), MciasError> {
self.delete(&format!("/v1/token/{jti}")).await
}
// ---- PG credentials (admin only) ----
/// Get decrypted Postgres credentials for an account.
pub async fn get_pg_creds(&self, account_id: &str) -> Result<PgCreds, MciasError> {
self.get(&format!("/v1/accounts/{account_id}/pgcreds"))
.await
}
/// Store Postgres credentials for an account.
pub async fn set_pg_creds(
&self,
account_id: &str,
host: &str,
port: u16,
database: &str,
username: &str,
password: &str,
) -> Result<(), MciasError> {
let body = serde_json::json!({
"host": host,
"port": port,
"database": database,
"username": username,
"password": password,
});
self.put_no_content(&format!("/v1/accounts/{account_id}/pgcreds"), &body)
.await
}
// ---- HTTP helpers ----
/// Build a request with the Authorization header set from the stored token.
/// Security: the token is read under a read-lock and is not logged.
async fn auth_header(&self) -> Option<header::HeaderValue> {
let guard = self.token.read().await;
guard.as_deref().and_then(|tok| {
header::HeaderValue::from_str(&format!("Bearer {tok}")).ok()
})
}
async fn get<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T, MciasError> {
let mut req = self.http.get(format!("{}{path}", self.base_url));
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.decode(resp).await
}
async fn get_empty(&self, path: &str) -> Result<(), MciasError> {
let mut req = self.http.get(format!("{}{path}", self.base_url));
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.expect_success(resp).await
}
async fn post<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &B,
) -> Result<T, MciasError> {
let mut req = self
.http
.post(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.decode(resp).await
}
async fn post_expect_status<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &B,
expected: StatusCode,
) -> Result<T, MciasError> {
let mut req = self
.http
.post(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
if resp.status() == expected {
return resp
.json::<T>()
.await
.map_err(|e| MciasError::Decode(e.to_string()));
}
Err(self.error_from_response(resp).await)
}
async fn post_empty(&self, path: &str) -> Result<(), MciasError> {
let mut req = self
.http
.post(format!("{}{path}", self.base_url))
.header(header::CONTENT_LENGTH, "0");
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.expect_success(resp).await
}
async fn patch<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &B,
) -> Result<T, MciasError> {
let mut req = self
.http
.patch(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.decode(resp).await
}
async fn put_no_content<B: Serialize + ?Sized>(&self, path: &str, body: &B) -> Result<(), MciasError> {
let mut req = self
.http
.put(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.expect_success(resp).await
}
async fn delete(&self, path: &str) -> Result<(), MciasError> {
let mut req = self.http.delete(format!("{}{path}", self.base_url));
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.expect_success(resp).await
}
async fn decode<T: for<'de> Deserialize<'de>>(
&self,
resp: reqwest::Response,
) -> Result<T, MciasError> {
if resp.status().is_success() {
return resp
.json::<T>()
.await
.map_err(|e| MciasError::Decode(e.to_string()));
}
Err(self.error_from_response(resp).await)
}
async fn expect_success(&self, resp: reqwest::Response) -> Result<(), MciasError> {
if resp.status().is_success() {
return Ok(());
}
Err(self.error_from_response(resp).await)
}
async fn error_from_response(&self, resp: reqwest::Response) -> MciasError {
let status = resp.status();
let message = resp
.json::<ErrorResponse>()
.await
.map(|e| if e.error.is_empty() { status.to_string() } else { e.error })
.unwrap_or_else(|_| status.to_string());
match status {
StatusCode::UNAUTHORIZED => MciasError::Auth(message),
StatusCode::FORBIDDEN => MciasError::Forbidden(message),
StatusCode::NOT_FOUND => MciasError::NotFound(message),
StatusCode::BAD_REQUEST => MciasError::InvalidInput(message),
StatusCode::CONFLICT => MciasError::Conflict(message),
s => MciasError::Server {
status: s.as_u16(),
message,
},
}
}
}

View File

@@ -0,0 +1 @@
{"rustc_fingerprint":14247534662873507473,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.91.1 (ed61e7d7e 2025-11-07)\nbinary: rustc\ncommit-hash: ed61e7d7e242494fb7057f2657300d9e77bb4fcb\ncommit-date: 2025-11-07\nhost: aarch64-apple-darwin\nrelease: 1.91.1\nLLVM version: 21.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/kyle/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""}},"successes":{}}

View File

@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/

View File

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
c5ced89412783353

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[\"perf-literal\", \"std\"]","declared_features":"[\"default\", \"logging\", \"perf-literal\", \"std\"]","target":7534583537114156500,"profile":5347358027863023418,"path":9018917292316982161,"deps":[[1363051979936526615,"memchr",false,9245471952160789708]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/aho-corasick-28f87e939d4c721c/dep-lib-aho_corasick","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[]","declared_features":"[]","target":14508078720126780090,"profile":5347358027863023418,"path":4281972190121210328,"deps":[[13548984313718623784,"serde",false,16120970927335062175],[13795362694956882968,"serde_json",false,4117845875591086358]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/assert-json-diff-be78b649a0eddaee/dep-lib-assert_json_diff","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
e8c38ee247aa7954

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":5347358027863023418,"path":7991844974711207059,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atomic-waker-a55bbb641f718f5a/dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
a127bdcba5e37867

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":5347358027863023418,"path":16063635426002685251,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/base64-2f49d490615b4e49/dep-lib-base64","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
2d331b77805e9a04

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[]","declared_features":"[\"arbitrary\", \"bytemuck\", \"example_generated\", \"serde\", \"serde_core\", \"std\"]","target":7691312148208718491,"profile":5347358027863023418,"path":8614177946156764418,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/bitflags-eaedb70d6a41c30b/dep-lib-bitflags","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
8863bee26ad65812

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"extra-platforms\", \"serde\", \"std\"]","target":11402411492164584411,"profile":7855341030452660939,"path":9957043344816735784,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/bytes-9d0fd7b2ef5dbc06/dep-lib-bytes","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
6cc51f38803aae8d

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[]","declared_features":"[\"jobserver\", \"parallel\"]","target":11042037588551934598,"profile":9003321226815314314,"path":14917407608417183273,"deps":[[8410525223747752176,"shlex",false,1715702720107712345],[9159843920629750842,"find_msvc_tools",false,2771186347935837742]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cc-de54006d6d00ad47/dep-lib-cc","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
dfe3f4728b16209c

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[]","declared_features":"[\"core\", \"rustc-dep-of-std\"]","target":13840298032947503755,"profile":5347358027863023418,"path":17596718033636595651,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cfg-if-8b6c5d6bdcf1deb9/dep-lib-cfg_if","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
330793750225f046

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[\"default\", \"managed\", \"unmanaged\"]","declared_features":"[\"default\", \"managed\", \"rt_async-std_1\", \"rt_tokio_1\", \"serde\", \"unmanaged\"]","target":13835509349254682884,"profile":5347358027863023418,"path":15683544176102990216,"deps":[[2357570525450087091,"num_cpus",false,18058056838497664298],[3554703672530437239,"deadpool_runtime",false,16524056477937411507],[13298363700532491723,"tokio",false,634726864496810915],[17917672826516349275,"lazy_static",false,3006984610137439678]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/deadpool-1cc37ec91796c245/dep-lib-deadpool","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[]","declared_features":"[\"async-std_1\", \"tokio_1\"]","target":12160367133229451087,"profile":5347358027863023418,"path":13187418150853952588,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/deadpool-runtime-0c3bfe3b184e819d/dep-lib-deadpool_runtime","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
ea0f0f6f2a2e515b

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[]","declared_features":"[\"default\", \"std\"]","target":9331843185013996172,"profile":3033921117576893,"path":14663136940038511305,"deps":[[4289358735036141001,"proc_macro2",false,9691147391376955975],[10420560437213941093,"syn",false,5387550519180858585],[13111758008314797071,"quote",false,7524150538574845385]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/displaydoc-95590d2b87c9dab6/dep-lib-displaydoc","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
a0cadea7aeaa9e8d

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[]","declared_features":"[]","target":1524667692659508025,"profile":5347358027863023418,"path":18405681531942536603,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/equivalent-e12128825f2bed30/dep-lib-equivalent","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[]","declared_features":"[]","target":10620166500288925791,"profile":9003321226815314314,"path":5526718681476264472,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/find-msvc-tools-be39ce7d61c79dc4/dep-lib-find_msvc_tools","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
2cc50f9e9e3de195

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"std\"]","target":10248144769085601448,"profile":5347358027863023418,"path":6113750312496810284,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/fnv-7eafb3796651fc69/dep-lib-fnv","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":6496257856677244489,"profile":5347358027863023418,"path":15610404081906102017,"deps":[[6803352382179706244,"percent_encoding",false,10389927364073220351]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/form_urlencoded-701bfaa4b527b19a/dep-lib-form_urlencoded","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

Some files were not shown because too many files have changed in this diff Show More