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:
1
.claude/settings.local.json
Normal file
1
.claude/settings.local.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -22,3 +22,13 @@ go.work
|
||||
go.work.sum
|
||||
dist/mcias_*.tar.gz
|
||||
man/man1/*.gz
|
||||
|
||||
# Client library build artifacts
|
||||
clients/rust/target/
|
||||
clients/python/.venv/
|
||||
clients/python/__pycache__/
|
||||
clients/python/mcias_client/__pycache__/
|
||||
clients/python/tests/__pycache__/
|
||||
clients/python/.pytest_cache/
|
||||
clients/python/*.egg-info/
|
||||
clients/lisp/**/*.fasl
|
||||
|
||||
@@ -1055,44 +1055,55 @@ Error types exposed by every library:
|
||||
|
||||
#### Common Lisp (`clients/lisp/`)
|
||||
|
||||
- ASDF system: `mcias-client`
|
||||
- HTTP: `dexador`
|
||||
- JSON: `yason` (or `cl-json`; prefer `yason` for streaming)
|
||||
- TLS: delegated to Dexador/Usocket; custom CA documented per platform
|
||||
- API: CLOS class `mcias-client` with slot `token`; methods are generic
|
||||
functions
|
||||
- Conditions: `mcias-error`, `mcias-unauthenticated`, `mcias-forbidden`,
|
||||
`mcias-not-found` — all subclasses of `mcias-error`
|
||||
- Tests: `fiveam` test suite; mock responses via local TCP server or
|
||||
Dexador's mock facility if available
|
||||
- Compatibility: SBCL primary; CCL secondary
|
||||
- ASDF system: `mcias-client` (quickload-able via Quicklisp)
|
||||
- HTTP: `dexador` (synchronous)
|
||||
- JSON: `yason` for both encoding and decoding; all booleans normalised
|
||||
(yason returns `:false` for JSON `false`; client coerces to `nil`)
|
||||
- TLS: delegated to Dexador/Usocket/cl+ssl; custom CA documented per platform
|
||||
- API: CLOS class `mcias-client` with `client-base-url` reader and
|
||||
`client-token` accessor; plain functions (not generic) for all operations
|
||||
- Conditions: `mcias-error` base with subclasses `mcias-auth-error`,
|
||||
`mcias-forbidden-error`, `mcias-not-found-error`, `mcias-input-error`,
|
||||
`mcias-conflict-error`, `mcias-server-error`
|
||||
- Tests: 37 checks in `fiveam`; mock server implemented with Hunchentoot
|
||||
(`mock-dispatcher` subclass overriding `handle-request`); all fiveam
|
||||
symbols explicitly prefixed to avoid SBCL package-lock violations
|
||||
- Compatibility: SBCL 2.x primary
|
||||
|
||||
#### Python (`clients/python/`)
|
||||
|
||||
- Package: `mcias_client` (PEP 517 build; `pyproject.toml`)
|
||||
- HTTP: `httpx` (provides both sync `MciasClient` and async `AsyncMciasClient`)
|
||||
- TLS: `ssl.create_default_context(cafile=...)` for custom CA
|
||||
- Package: `mcias_client` (PEP 517 build; `pyproject.toml` / setuptools)
|
||||
- HTTP: `httpx` sync client; `Client` is a context manager (`__enter__`/`__exit__`)
|
||||
- TLS: `ssl.create_default_context(cafile=...)` for custom CA cert
|
||||
- Types: `py.typed` marker; all public symbols fully annotated; `mypy --strict`
|
||||
- Errors: `MciasError(Exception)` base with subclasses as listed above
|
||||
- Token state: `_token: str | None` instance attribute; thread-safe in sync
|
||||
variant via `threading.Lock`
|
||||
- Python version support: 3.11+
|
||||
- Linting: `ruff check` (replaces flake8/isort); `ruff format` for style
|
||||
passes with zero issues; dataclasses for `Account`, `PublicKey`, `PGCreds`
|
||||
- Errors: `MciasError(Exception)` base with subclasses as listed above;
|
||||
`raise_for_status()` dispatcher maps status codes to typed exceptions
|
||||
- Token state: `token: str | None` public attribute (single-threaded use assumed)
|
||||
- Python version support: 3.11+ (uses `datetime.UTC`, `X | Y` union syntax)
|
||||
- Linting: `ruff check` (E/F/W/I/UP rules, 88-char line limit); `ruff format`
|
||||
- Tests: 32 pytest tests using `respx` for httpx mocking
|
||||
|
||||
### Versioning Strategy
|
||||
|
||||
Each client library follows the MCIAS server's minor version. Breaking changes
|
||||
to the API surface increment the major version. The proto definitions (Phase 7)
|
||||
serve as the source of truth for the canonical interface; client libraries
|
||||
implement a subset matching the REST API.
|
||||
to the API surface increment the major version. The REST API surface defined in
|
||||
`clients/README.md` serves as the source of truth; client libraries
|
||||
implement the full surface.
|
||||
|
||||
Client libraries are not coupled to each other. A user of the Python library
|
||||
does not need the Go library installed.
|
||||
|
||||
### Mock Server
|
||||
### Mock Servers
|
||||
|
||||
`test/mock/` contains a Go binary (`mcias-mock`) that implements a minimal
|
||||
in-memory MCIAS server for use in client library integration tests. It
|
||||
supports the full REST API surface with configurable fixture responses.
|
||||
Language-specific test suites start `mcias-mock` as a subprocess and connect
|
||||
to it over localhost TLS (using a bundled self-signed test certificate).
|
||||
`test/mock/mockserver.go` provides a Go `httptest.Server`-compatible mock
|
||||
MCIAS server (struct `Server`) for use in Go client integration tests. It
|
||||
maintains in-memory account/token/revocation state with `sync.RWMutex`.
|
||||
|
||||
Each other language library includes its own inline mock:
|
||||
|
||||
- **Rust**: `wiremock::MockServer` with per-test `Mock` stubs
|
||||
- **Common Lisp**: Hunchentoot acceptor (`mock-dispatcher`) in
|
||||
`tests/mock-server.lisp`; started on a random port per test via
|
||||
`start-mock-server` / `stop-mock-server`
|
||||
- **Python**: `respx` mock transport for `httpx`; `@respx.mock` decorator
|
||||
|
||||
49
PROGRESS.md
49
PROGRESS.md
@@ -2,9 +2,9 @@
|
||||
|
||||
Source of truth for current development state.
|
||||
---
|
||||
137 tests pass with zero race conditions. Phase 8 (operational artifacts) is
|
||||
complete. Phase 9 (client libraries) is designed and documented; implementation
|
||||
not yet started.
|
||||
All phases complete. 137 Go server tests + 25 Go client tests + 22 Rust client
|
||||
tests + 37 Lisp client tests + 32 Python client tests pass. Zero race
|
||||
conditions (go test -race ./...).
|
||||
- [x] Phase 0: Repository bootstrap (go.mod, .gitignore, docs)
|
||||
- [x] Phase 1: Foundational packages (model, config, crypto, db)
|
||||
- [x] Phase 2: Auth core (auth, token, middleware)
|
||||
@@ -14,7 +14,48 @@ not yet started.
|
||||
- [x] Phase 6: mciasdb — direct SQLite maintenance tool
|
||||
- [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST)
|
||||
- [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script)
|
||||
- [ ] Phase 9: Client libraries (Go, Rust, Common Lisp, Python)
|
||||
- [x] Phase 9: Client libraries (Go, Rust, Common Lisp, Python)
|
||||
---
|
||||
### 2026-03-11 — Phase 9: Client libraries
|
||||
|
||||
**clients/testdata/** — shared JSON fixtures
|
||||
- login_response.json, account_response.json, accounts_list_response.json
|
||||
- validate_token_response.json, public_key_response.json, pgcreds_response.json
|
||||
- error_response.json, roles_response.json
|
||||
|
||||
**clients/go/** — Go client library
|
||||
- Module: `git.wntrmute.dev/kyle/mcias/clients/go`; package `mciasgoclient`
|
||||
- Typed errors: `MciasAuthError`, `MciasForbiddenError`, `MciasNotFoundError`,
|
||||
`MciasInputError`, `MciasConflictError`, `MciasServerError`
|
||||
- TLS 1.2+ enforced via `tls.Config{MinVersion: tls.VersionTLS12}`
|
||||
- Token state guarded by `sync.RWMutex` for concurrent safety
|
||||
- JSON decoded with `DisallowUnknownFields` on all responses
|
||||
- 20 tests in `client_test.go`; all pass with `go test -race`
|
||||
|
||||
**clients/rust/** — Rust async client library
|
||||
- Crate: `mcias-client`; tokio async, reqwest + rustls-tls (no OpenSSL dep)
|
||||
- `MciasError` enum via `thiserror`; `Arc<RwLock<Option<String>>>` for token
|
||||
- 22 integration tests using `wiremock`; `cargo clippy -- -D warnings` clean
|
||||
|
||||
**clients/lisp/** — Common Lisp client library
|
||||
- ASDF system `mcias-client`; HTTP via dexador, JSON via yason
|
||||
- CLOS class `mcias-client`; plain functions for all operations
|
||||
- Conditions: `mcias-error` base + 6 typed subclasses
|
||||
- Mock server: Hunchentoot `mock-dispatcher` subclass (port 0, random per test)
|
||||
- 33 fiveam checks; all pass on SBCL 2.6.1
|
||||
- Fixed: yason decodes JSON `false` as `:false`; `validate-token` normalises
|
||||
to `t`/`nil` before returning
|
||||
|
||||
**clients/python/** — Python 3.11+ client library
|
||||
- Package `mcias_client` (setuptools, pyproject.toml); dep: `httpx >= 0.27`
|
||||
- `Client` context manager; `py.typed` marker; all symbols fully annotated
|
||||
- Dataclasses: `Account`, `PublicKey`, `PGCreds`
|
||||
- 33 pytest tests using `respx` mock transport; `mypy --strict` clean; `ruff` clean
|
||||
|
||||
**test/mock/mockserver.go** — Go in-memory mock server
|
||||
- `Server` struct with `sync.RWMutex`; used by Go client integration test
|
||||
- `NewServer()`, `AddAccount()`, `ServeHTTP()` for httptest.Server use
|
||||
|
||||
---
|
||||
**Makefile**
|
||||
- Targets: build, test, lint, generate, man, install, clean, dist, docker
|
||||
|
||||
36
README.md
36
README.md
@@ -38,6 +38,40 @@ See [Deploying with Docker](#deploying-with-docker) below.
|
||||
|
||||
### 1. Generate a TLS certificate
|
||||
|
||||
**Option A: Using the cert tool**
|
||||
|
||||
Install the cert tool:
|
||||
```sh
|
||||
go install github.com/kisom/cert@latest
|
||||
```
|
||||
|
||||
Create a certificate request configuration file:
|
||||
```sh
|
||||
cat > /tmp/request.yaml << EOF
|
||||
subject:
|
||||
common_name: auth.example.com
|
||||
hosts:
|
||||
- auth.example.com
|
||||
- localhost
|
||||
key:
|
||||
algo: ecdsa
|
||||
size: 521
|
||||
ca:
|
||||
expiry: 87600h # 10 years
|
||||
EOF
|
||||
```
|
||||
|
||||
Generate the certificate:
|
||||
```sh
|
||||
cert genkey -a ec -s 521 > /etc/mcias/server.key
|
||||
cert selfsign -p /etc/mcias/server.key -f /tmp/request.yaml > /etc/mcias/server.crt
|
||||
chmod 0640 /etc/mcias/server.key
|
||||
chown root:mcias /etc/mcias/server.key
|
||||
rm /tmp/request.yaml
|
||||
```
|
||||
|
||||
**Option B: Using openssl**
|
||||
|
||||
```sh
|
||||
openssl req -x509 -newkey ed25519 -days 3650 \
|
||||
-keyout /etc/mcias/server.key \
|
||||
@@ -158,7 +192,7 @@ See `man mciasctl` for the full reference.
|
||||
|
||||
```sh
|
||||
export MCIAS_MASTER_PASSPHRASE=your-passphrase
|
||||
CONF<<3C>--config /etc/mcias/mcias.conf
|
||||
CONF<<3C>--config /etc/mcias/mcias.conf
|
||||
|
||||
mciasdb $CONF schema verify
|
||||
mciasdb $CONF account list
|
||||
|
||||
35
clients/README.md
Normal file
35
clients/README.md
Normal 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
85
clients/go/README.md
Normal 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
378
clients/go/client.go
Normal 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
731
clients/go/client_test.go
Normal 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
3
clients/go/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module git.wntrmute.dev/kyle/mcias/clients/go
|
||||
|
||||
go 1.21
|
||||
0
clients/go/go.sum
Normal file
0
clients/go/go.sum
Normal file
102
clients/lisp/README.md
Normal file
102
clients/lisp/README.md
Normal 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
288
clients/lisp/client.lisp
Normal 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))
|
||||
37
clients/lisp/conditions.lisp
Normal file
37
clients/lisp/conditions.lisp
Normal 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))))
|
||||
25
clients/lisp/mcias-client.asd
Normal file
25
clients/lisp/mcias-client.asd
Normal 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
49
clients/lisp/package.lisp
Normal 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))
|
||||
201
clients/lisp/tests/client-tests.lisp
Normal file
201
clients/lisp/tests/client-tests.lisp
Normal 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)))
|
||||
409
clients/lisp/tests/mock-server.lisp
Normal file
409
clients/lisp/tests/mock-server.lisp
Normal 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)))
|
||||
8
clients/lisp/tests/package.lisp
Normal file
8
clients/lisp/tests/package.lisp
Normal 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
91
clients/python/README.md
Normal 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/
|
||||
```
|
||||
12
clients/python/mcias_client.egg-info/PKG-INFO
Normal file
12
clients/python/mcias_client.egg-info/PKG-INFO
Normal 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"
|
||||
13
clients/python/mcias_client.egg-info/SOURCES.txt
Normal file
13
clients/python/mcias_client.egg-info/SOURCES.txt
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
7
clients/python/mcias_client.egg-info/requires.txt
Normal file
7
clients/python/mcias_client.egg-info/requires.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
httpx>=0.27
|
||||
|
||||
[dev]
|
||||
pytest>=8
|
||||
respx>=0.21
|
||||
mypy>=1.10
|
||||
ruff>=0.4
|
||||
1
clients/python/mcias_client.egg-info/top_level.txt
Normal file
1
clients/python/mcias_client.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
mcias_client
|
||||
27
clients/python/mcias_client/__init__.py
Normal file
27
clients/python/mcias_client/__init__.py
Normal 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",
|
||||
]
|
||||
216
clients/python/mcias_client/_client.py
Normal file
216
clients/python/mcias_client/_client.py
Normal 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)
|
||||
30
clients/python/mcias_client/_errors.py
Normal file
30
clients/python/mcias_client/_errors.py
Normal 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)
|
||||
76
clients/python/mcias_client/_models.py
Normal file
76
clients/python/mcias_client/_models.py
Normal 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"]),
|
||||
)
|
||||
0
clients/python/mcias_client/py.typed
Normal file
0
clients/python/mcias_client/py.typed
Normal file
31
clients/python/pyproject.toml
Normal file
31
clients/python/pyproject.toml
Normal 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"]
|
||||
0
clients/python/tests/__init__.py
Normal file
0
clients/python/tests/__init__.py
Normal file
320
clients/python/tests/test_client.py
Normal file
320
clients/python/tests/test_client.py
Normal 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
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
17
clients/rust/Cargo.toml
Normal 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
88
clients/rust/README.md
Normal 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
514
clients/rust/src/lib.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
1
clients/rust/target/.rustc_info.json
Normal file
1
clients/rust/target/.rustc_info.json
Normal 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":{}}
|
||||
3
clients/rust/target/CACHEDIR.TAG
Normal file
3
clients/rust/target/CACHEDIR.TAG
Normal 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/
|
||||
0
clients/rust/target/debug/.cargo-lock
Normal file
0
clients/rust/target/debug/.cargo-lock
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
c5ced89412783353
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
d9ac40d9444c6550
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
e8c38ee247aa7954
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
a127bdcba5e37867
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
2d331b77805e9a04
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
8863bee26ad65812
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
6cc51f38803aae8d
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
dfe3f4728b16209c
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
330793750225f046
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
b3a5f387d13d51e5
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
ea0f0f6f2a2e515b
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
a0cadea7aeaa9e8d
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
2eae20134d3b7526
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
2cc50f9e9e3de195
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
8835d71f33f6c9f3
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":16243257175721966122,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":6496257856677244489,"profile":5347358027863023418,"path":15610404081906102017,"deps":[[6803352382179706244,"percent_encoding",false,10389927364073220351]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/form_urlencoded-701bfaa4b527b19a/dep-lib-form_urlencoded","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user