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

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}

View File

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

View File

@@ -0,0 +1 @@
aa9a94ab748eaff6

View File

@@ -0,0 +1 @@
{"rustc":16243257175721966122,"features":"[\"alloc\", \"async-await\", \"default\", \"executor\", \"futures-executor\", \"std\"]","declared_features":"[\"alloc\", \"async-await\", \"bilock\", \"cfg-target-has-atomic\", \"compat\", \"default\", \"executor\", \"futures-executor\", \"io-compat\", \"spin\", \"std\", \"thread-pool\", \"unstable\", \"write-all-vectored\"]","target":7465627196321967167,"profile":17669703692130904899,"path":3204528599378229077,"deps":[[270634688040536827,"futures_sink",false,5748329606332216778],[302948626015856208,"futures_core",false,16395551869408918501],[5898568623609459682,"futures_util",false,17637614804480441737],[9128867168860799549,"futures_channel",false,14115314800078754606],[12256881686772805731,"futures_task",false,2612834557237516838],[17736352539849991289,"futures_io",false,5875146560703383865],[18054922619297524099,"futures_executor",false,12869042343505724837]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/futures-1f82e392f9c7d455/dep-lib-futures","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

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