From da4126c1a9da6b34cd0b9a728b59a604517b66e5 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 11 Mar 2026 16:38:32 -0700 Subject: [PATCH] =?UTF-8?q?Implement=20Phase=209:=20client=20libraries=20(?= =?UTF-8?q?Go,=20Rust,=20Lisp,=20Python)=20-=20clients/README.md:=20canoni?= =?UTF-8?q?cal=20API=20surface=20and=20error=20type=20reference=20-=20clie?= =?UTF-8?q?nts/testdata/:=20shared=20JSON=20response=20fixtures=20-=20clie?= =?UTF-8?q?nts/go/:=20mciasgoclient=20package;=20net/http=20+=20TLS=201.2+?= =?UTF-8?q?;=20sync.RWMutex=20=20=20token=20state;=20DisallowUnknownFields?= =?UTF-8?q?=20on=20all=20decoders;=2025=20tests=20pass=20-=20clients/rust/?= =?UTF-8?q?:=20async=20mcias-client=20crate;=20reqwest+rustls=20(no=20Open?= =?UTF-8?q?SSL);=20=20=20thiserror=20MciasError=20enum;=20Arc=20to?= =?UTF-8?q?ken=20state;=2022+1=20tests=20pass;=20=20=20cargo=20clippy=20-D?= =?UTF-8?q?=20warnings=20clean=20-=20clients/lisp/:=20ASDF=20mcias-client;?= =?UTF-8?q?=20dexador=20HTTP,=20yason=20JSON;=20mcias-error=20=20=20condit?= =?UTF-8?q?ion=20hierarchy;=20Hunchentoot=20mock-dispatcher;=2037=20fiveam?= =?UTF-8?q?=20checks=20pass=20=20=20on=20SBCL=202.6.1;=20yason=20boolean?= =?UTF-8?q?=20normalisation=20in=20validate-token=20-=20clients/python/:?= =?UTF-8?q?=20mcias=5Fclient=20package=20(Python=203.11+);=20httpx=20sync;?= =?UTF-8?q?=20=20=20py.typed;=20dataclasses;=2032=20pytest=20tests;=20mypy?= =?UTF-8?q?=20--strict=20+=20ruff=20clean=20-=20test/mock/mockserver.go:?= =?UTF-8?q?=20in-memory=20mock=20server=20for=20Go=20client=20tests=20-=20?= =?UTF-8?q?ARCHITECTURE.md=20=C2=A719:=20updated=20per-language=20notes=20?= =?UTF-8?q?to=20match=20implementation=20-=20PROGRESS.md:=20Phase=209=20ma?= =?UTF-8?q?rked=20complete=20-=20.gitignore:=20exclude=20clients/rust/targ?= =?UTF-8?q?et/,=20python=20.venv,=20.pytest=5Fcache,=20=20=20.fasl=20files?= =?UTF-8?q?=20Security:=20token=20never=20logged=20or=20exposed=20in=20err?= =?UTF-8?q?or=20messages=20in=20any=20library;=20TLS=20enforced=20in=20all?= =?UTF-8?q?=20four=20languages;=20token=20stored=20under=20lock/mutex/RwLo?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 1 + .gitignore | 10 + ARCHITECTURE.md | 67 +- PROGRESS.md | 49 +- README.md | 36 +- clients/README.md | 35 + clients/go/README.md | 85 + clients/go/client.go | 378 ++++ clients/go/client_test.go | 731 ++++++++ clients/go/go.mod | 3 + clients/go/go.sum | 0 clients/lisp/README.md | 102 ++ clients/lisp/client.lisp | 288 +++ clients/lisp/conditions.lisp | 37 + clients/lisp/mcias-client.asd | 25 + clients/lisp/package.lisp | 49 + clients/lisp/tests/client-tests.lisp | 201 ++ clients/lisp/tests/mock-server.lisp | 409 +++++ clients/lisp/tests/package.lisp | 8 + clients/python/README.md | 91 + clients/python/mcias_client.egg-info/PKG-INFO | 12 + .../python/mcias_client.egg-info/SOURCES.txt | 13 + .../dependency_links.txt | 1 + .../python/mcias_client.egg-info/requires.txt | 7 + .../mcias_client.egg-info/top_level.txt | 1 + clients/python/mcias_client/__init__.py | 27 + clients/python/mcias_client/_client.py | 216 +++ clients/python/mcias_client/_errors.py | 30 + clients/python/mcias_client/_models.py | 76 + clients/python/mcias_client/py.typed | 0 clients/python/pyproject.toml | 31 + clients/python/tests/__init__.py | 0 clients/python/tests/test_client.py | 320 ++++ clients/rust/Cargo.lock | 1619 +++++++++++++++++ clients/rust/Cargo.toml | 17 + clients/rust/README.md | 88 + clients/rust/src/lib.rs | 514 ++++++ clients/rust/tests/client_tests.rs | 485 +++++ clients/testdata/account_response.json | 9 + clients/testdata/accounts_list_response.json | 20 + clients/testdata/error_response.json | 3 + clients/testdata/login_response.json | 4 + clients/testdata/pgcreds_response.json | 7 + clients/testdata/public_key_response.json | 7 + clients/testdata/roles_response.json | 3 + clients/testdata/validate_token_response.json | 6 + test/mock/mockserver.go | 516 ++++++ 47 files changed, 6604 insertions(+), 33 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 clients/README.md create mode 100644 clients/go/README.md create mode 100644 clients/go/client.go create mode 100644 clients/go/client_test.go create mode 100644 clients/go/go.mod create mode 100644 clients/go/go.sum create mode 100644 clients/lisp/README.md create mode 100644 clients/lisp/client.lisp create mode 100644 clients/lisp/conditions.lisp create mode 100644 clients/lisp/mcias-client.asd create mode 100644 clients/lisp/package.lisp create mode 100644 clients/lisp/tests/client-tests.lisp create mode 100644 clients/lisp/tests/mock-server.lisp create mode 100644 clients/lisp/tests/package.lisp create mode 100644 clients/python/README.md create mode 100644 clients/python/mcias_client.egg-info/PKG-INFO create mode 100644 clients/python/mcias_client.egg-info/SOURCES.txt create mode 100644 clients/python/mcias_client.egg-info/dependency_links.txt create mode 100644 clients/python/mcias_client.egg-info/requires.txt create mode 100644 clients/python/mcias_client.egg-info/top_level.txt create mode 100644 clients/python/mcias_client/__init__.py create mode 100644 clients/python/mcias_client/_client.py create mode 100644 clients/python/mcias_client/_errors.py create mode 100644 clients/python/mcias_client/_models.py create mode 100644 clients/python/mcias_client/py.typed create mode 100644 clients/python/pyproject.toml create mode 100644 clients/python/tests/__init__.py create mode 100644 clients/python/tests/test_client.py create mode 100644 clients/rust/Cargo.lock create mode 100644 clients/rust/Cargo.toml create mode 100644 clients/rust/README.md create mode 100644 clients/rust/src/lib.rs create mode 100644 clients/rust/tests/client_tests.rs create mode 100644 clients/testdata/account_response.json create mode 100644 clients/testdata/accounts_list_response.json create mode 100644 clients/testdata/error_response.json create mode 100644 clients/testdata/login_response.json create mode 100644 clients/testdata/pgcreds_response.json create mode 100644 clients/testdata/public_key_response.json create mode 100644 clients/testdata/roles_response.json create mode 100644 clients/testdata/validate_token_response.json create mode 100644 test/mock/mockserver.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1 @@ +{} diff --git a/.gitignore b/.gitignore index 4be2bbb..f93719b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 51097cd..d9f63a8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/PROGRESS.md b/PROGRESS.md index 12843ed..6ed561d 100644 --- a/PROGRESS.md +++ b/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>>` 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 diff --git a/README.md b/README.md index 55d3d43..0359421 100644 --- a/README.md +++ b/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<½--config /etc/mcias/mcias.conf +CONF<�--config /etc/mcias/mcias.conf mciasdb $CONF schema verify mciasdb $CONF account list diff --git a/clients/README.md b/clients/README.md new file mode 100644 index 0000000..b2dfe10 --- /dev/null +++ b/clients/README.md @@ -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` diff --git a/clients/go/README.md b/clients/go/README.md new file mode 100644 index 0000000..920c53e --- /dev/null +++ b/clients/go/README.md @@ -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 ./... +``` diff --git a/clients/go/client.go b/clients/go/client.go new file mode 100644 index 0000000..272bb1d --- /dev/null +++ b/clients/go/client.go @@ -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) +} diff --git a/clients/go/client_test.go b/clients/go/client_test.go new file mode 100644 index 0000000..777a104 --- /dev/null +++ b/clients/go/client_test.go @@ -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()) + } +} diff --git a/clients/go/go.mod b/clients/go/go.mod new file mode 100644 index 0000000..ffcced1 --- /dev/null +++ b/clients/go/go.mod @@ -0,0 +1,3 @@ +module git.wntrmute.dev/kyle/mcias/clients/go + +go 1.21 diff --git a/clients/go/go.sum b/clients/go/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/clients/lisp/README.md b/clients/lisp/README.md new file mode 100644 index 0000000..da37df4 --- /dev/null +++ b/clients/lisp/README.md @@ -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)' +``` diff --git a/clients/lisp/client.lisp b/clients/lisp/client.lisp new file mode 100644 index 0000000..3010f35 --- /dev/null +++ b/clients/lisp/client.lisp @@ -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 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)) diff --git a/clients/lisp/conditions.lisp b/clients/lisp/conditions.lisp new file mode 100644 index 0000000..395e310 --- /dev/null +++ b/clients/lisp/conditions.lisp @@ -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)))) diff --git a/clients/lisp/mcias-client.asd b/clients/lisp/mcias-client.asd new file mode 100644 index 0000000..044b79d --- /dev/null +++ b/clients/lisp/mcias-client.asd @@ -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))) diff --git a/clients/lisp/package.lisp b/clients/lisp/package.lisp new file mode 100644 index 0000000..ec35a7b --- /dev/null +++ b/clients/lisp/package.lisp @@ -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)) diff --git a/clients/lisp/tests/client-tests.lisp b/clients/lisp/tests/client-tests.lisp new file mode 100644 index 0000000..4b73709 --- /dev/null +++ b/clients/lisp/tests/client-tests.lisp @@ -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))) diff --git a/clients/lisp/tests/mock-server.lisp b/clients/lisp/tests/mock-server.lisp new file mode 100644 index 0000000..4fd61e2 --- /dev/null +++ b/clients/lisp/tests/mock-server.lisp @@ -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))) diff --git a/clients/lisp/tests/package.lisp b/clients/lisp/tests/package.lisp new file mode 100644 index 0000000..623c642 --- /dev/null +++ b/clients/lisp/tests/package.lisp @@ -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)) diff --git a/clients/python/README.md b/clients/python/README.md new file mode 100644 index 0000000..80d8250 --- /dev/null +++ b/clients/python/README.md @@ -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/ +``` diff --git a/clients/python/mcias_client.egg-info/PKG-INFO b/clients/python/mcias_client.egg-info/PKG-INFO new file mode 100644 index 0000000..65fdfee --- /dev/null +++ b/clients/python/mcias_client.egg-info/PKG-INFO @@ -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" diff --git a/clients/python/mcias_client.egg-info/SOURCES.txt b/clients/python/mcias_client.egg-info/SOURCES.txt new file mode 100644 index 0000000..4610d33 --- /dev/null +++ b/clients/python/mcias_client.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/clients/python/mcias_client.egg-info/dependency_links.txt b/clients/python/mcias_client.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/clients/python/mcias_client.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/clients/python/mcias_client.egg-info/requires.txt b/clients/python/mcias_client.egg-info/requires.txt new file mode 100644 index 0000000..d978f68 --- /dev/null +++ b/clients/python/mcias_client.egg-info/requires.txt @@ -0,0 +1,7 @@ +httpx>=0.27 + +[dev] +pytest>=8 +respx>=0.21 +mypy>=1.10 +ruff>=0.4 diff --git a/clients/python/mcias_client.egg-info/top_level.txt b/clients/python/mcias_client.egg-info/top_level.txt new file mode 100644 index 0000000..f2abb0c --- /dev/null +++ b/clients/python/mcias_client.egg-info/top_level.txt @@ -0,0 +1 @@ +mcias_client diff --git a/clients/python/mcias_client/__init__.py b/clients/python/mcias_client/__init__.py new file mode 100644 index 0000000..65cfdb5 --- /dev/null +++ b/clients/python/mcias_client/__init__.py @@ -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", +] diff --git a/clients/python/mcias_client/_client.py b/clients/python/mcias_client/_client.py new file mode 100644 index 0000000..67f1688 --- /dev/null +++ b/clients/python/mcias_client/_client.py @@ -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) diff --git a/clients/python/mcias_client/_errors.py b/clients/python/mcias_client/_errors.py new file mode 100644 index 0000000..fa21a0d --- /dev/null +++ b/clients/python/mcias_client/_errors.py @@ -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) diff --git a/clients/python/mcias_client/_models.py b/clients/python/mcias_client/_models.py new file mode 100644 index 0000000..0fa026d --- /dev/null +++ b/clients/python/mcias_client/_models.py @@ -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"]), + ) diff --git a/clients/python/mcias_client/py.typed b/clients/python/mcias_client/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml new file mode 100644 index 0000000..caebae8 --- /dev/null +++ b/clients/python/pyproject.toml @@ -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"] diff --git a/clients/python/tests/__init__.py b/clients/python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clients/python/tests/test_client.py b/clients/python/tests/test_client.py new file mode 100644 index 0000000..9e32919 --- /dev/null +++ b/clients/python/tests/test_client.py @@ -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 diff --git a/clients/rust/Cargo.lock b/clients/rust/Cargo.lock new file mode 100644 index 0000000..624592f --- /dev/null +++ b/clients/rust/Cargo.lock @@ -0,0 +1,1619 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mcias-client" +version = "0.1.0" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "wiremock", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/clients/rust/Cargo.toml b/clients/rust/Cargo.toml new file mode 100644 index 0000000..6045070 --- /dev/null +++ b/clients/rust/Cargo.toml @@ -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"] } diff --git a/clients/rust/README.md b/clients/rust/README.md new file mode 100644 index 0000000..aabd869 --- /dev/null +++ b/clients/rust/README.md @@ -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> { + 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>>` for safe concurrent access. + +## Running Tests + +```sh +cargo test +cargo clippy -- -D warnings +``` diff --git a/clients/rust/src/lib.rs b/clients/rust/src/lib.rs new file mode 100644 index 0000000..fa4a856 --- /dev/null +++ b/clients/rust/src/lib.rs @@ -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> { +//! 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>` 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, + #[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>, + + /// Optional pre-existing bearer token. + pub token: Option, +} + +// ---- 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>` so clones share the token. + token: Arc>>, +} + +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 { + 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 { + self.token.read().await.clone() + } + + /// Replace the stored bearer token. + pub async fn set_token(&self, tok: Option) { + *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 { + 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 { + 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 { + 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, MciasError> { + self.get("/v1/accounts").await + } + + /// Get a single account by UUID. + pub async fn get_account(&self, id: &str) -> Result { + 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 { + 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, 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 { + 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 { + let guard = self.token.read().await; + guard.as_deref().and_then(|tok| { + header::HeaderValue::from_str(&format!("Bearer {tok}")).ok() + }) + } + + async fn get Deserialize<'de>>(&self, path: &str) -> Result { + 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 Deserialize<'de>>( + &self, + path: &str, + body: &B, + ) -> Result { + 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 Deserialize<'de>>( + &self, + path: &str, + body: &B, + expected: StatusCode, + ) -> Result { + 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::() + .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 Deserialize<'de>>( + &self, + path: &str, + body: &B, + ) -> Result { + 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(&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 Deserialize<'de>>( + &self, + resp: reqwest::Response, + ) -> Result { + if resp.status().is_success() { + return resp + .json::() + .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::() + .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, + }, + } + } +} diff --git a/clients/rust/tests/client_tests.rs b/clients/rust/tests/client_tests.rs new file mode 100644 index 0000000..f433d8c --- /dev/null +++ b/clients/rust/tests/client_tests.rs @@ -0,0 +1,485 @@ +use mcias_client::{Client, ClientOptions, MciasError}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +async fn admin_client(server: &MockServer) -> Client { + Client::new(&server.uri(), ClientOptions { + token: Some("admin-token".to_string()), + ..Default::default() + }) + .unwrap() +} + +fn json_body(body: serde_json::Value) -> ResponseTemplate { + ResponseTemplate::new(200) + .set_body_json(body) + .insert_header("content-type", "application/json") +} + +fn json_body_status(status: u16, body: serde_json::Value) -> ResponseTemplate { + ResponseTemplate::new(status) + .set_body_json(body) + .insert_header("content-type", "application/json") +} + +// ---- health ---- + +#[tokio::test] +async fn test_health_ok() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/health")) + .respond_with(json_body(serde_json::json!({"status": "ok"}))) + .mount(&server) + .await; + + let c = Client::new(&server.uri(), ClientOptions::default()).unwrap(); + c.health().await.expect("health should succeed"); +} + +#[tokio::test] +async fn test_health_server_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/health")) + .respond_with(json_body_status(500, serde_json::json!({"error": "oops"}))) + .mount(&server) + .await; + + let c = Client::new(&server.uri(), ClientOptions::default()).unwrap(); + let err = c.health().await.unwrap_err(); + assert!(matches!(err, MciasError::Server { .. }), "expected Server error, got {err:?}"); +} + +// ---- public key ---- + +#[tokio::test] +async fn test_get_public_key() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/keys/public")) + .respond_with(json_body(serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }))) + .mount(&server) + .await; + + let c = Client::new(&server.uri(), ClientOptions::default()).unwrap(); + let pk = c.get_public_key().await.expect("get_public_key should succeed"); + assert_eq!(pk.kty, "OKP"); + assert_eq!(pk.crv, "Ed25519"); +} + +// ---- login ---- + +#[tokio::test] +async fn test_login_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/auth/login")) + .respond_with(json_body(serde_json::json!({ + "token": "jwt-token", + "expires_at": "2099-01-01T00:00:00Z" + }))) + .mount(&server) + .await; + + let c = Client::new(&server.uri(), ClientOptions::default()).unwrap(); + let (tok, exp) = c.login("alice", "s3cret", None).await.unwrap(); + assert_eq!(tok, "jwt-token"); + assert_eq!(exp, "2099-01-01T00:00:00Z"); + // Token stored in client. + assert_eq!(c.token().await.as_deref(), Some("jwt-token")); +} + +#[tokio::test] +async fn test_login_bad_credentials() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/auth/login")) + .respond_with(json_body_status(401, serde_json::json!({"error": "invalid credentials"}))) + .mount(&server) + .await; + + let c = Client::new(&server.uri(), ClientOptions::default()).unwrap(); + let err = c.login("alice", "wrong", None).await.unwrap_err(); + assert!(matches!(err, MciasError::Auth(_)), "expected Auth error, got {err:?}"); +} + +// ---- logout ---- + +#[tokio::test] +async fn test_logout_clears_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/auth/logout")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + let c = Client::new(&server.uri(), ClientOptions { + token: Some("existing-token".to_string()), + ..Default::default() + }) + .unwrap(); + c.logout().await.unwrap(); + assert!(c.token().await.is_none(), "token should be cleared after logout"); +} + +// ---- renew ---- + +#[tokio::test] +async fn test_renew_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/auth/renew")) + .respond_with(json_body(serde_json::json!({ + "token": "new-token", + "expires_at": "2099-06-01T00:00:00Z" + }))) + .mount(&server) + .await; + + let c = Client::new(&server.uri(), ClientOptions { + token: Some("old-token".to_string()), + ..Default::default() + }) + .unwrap(); + let (tok, _) = c.renew_token().await.unwrap(); + assert_eq!(tok, "new-token"); + assert_eq!(c.token().await.as_deref(), Some("new-token")); +} + +// ---- validate token ---- + +#[tokio::test] +async fn test_validate_token_valid() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/token/validate")) + .respond_with(json_body(serde_json::json!({ + "valid": true, + "sub": "uuid-123", + "roles": ["admin"], + "expires_at": "2099-01-01T00:00:00Z" + }))) + .mount(&server) + .await; + + let c = Client::new(&server.uri(), ClientOptions::default()).unwrap(); + let claims = c.validate_token("good-token").await.unwrap(); + assert!(claims.valid); + assert_eq!(claims.sub, "uuid-123"); + assert_eq!(claims.roles, vec!["admin"]); +} + +#[tokio::test] +async fn test_validate_token_invalid() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/token/validate")) + .respond_with(json_body(serde_json::json!({"valid": false}))) + .mount(&server) + .await; + + let c = Client::new(&server.uri(), ClientOptions::default()).unwrap(); + let claims = c.validate_token("garbage").await.unwrap(); + assert!(!claims.valid, "expected valid=false"); +} + +// ---- accounts ---- + +#[tokio::test] +async fn test_create_account() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/accounts")) + .respond_with( + ResponseTemplate::new(201) + .set_body_json(serde_json::json!({ + "id": "new-uuid", + "username": "bob", + "account_type": "human", + "status": "active", + "created_at": "2023-11-15T12:00:00Z", + "updated_at": "2023-11-15T12:00:00Z", + "totp_enabled": false + })) + .insert_header("content-type", "application/json"), + ) + .mount(&server) + .await; + + let c = admin_client(&server).await; + let a = c.create_account("bob", Some("pass123"), "human").await.unwrap(); + assert_eq!(a.username, "bob"); + assert_eq!(a.account_type, "human"); + assert_eq!(a.status, "active"); +} + +#[tokio::test] +async fn test_create_account_conflict() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/accounts")) + .respond_with(json_body_status(409, serde_json::json!({"error": "username already exists"}))) + .mount(&server) + .await; + + let c = admin_client(&server).await; + let err = c.create_account("dup", Some("pass"), "human").await.unwrap_err(); + assert!(matches!(err, MciasError::Conflict(_)), "expected Conflict error, got {err:?}"); +} + +#[tokio::test] +async fn test_list_accounts() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/accounts")) + .respond_with(json_body(serde_json::json!([ + { + "id": "uuid-1", "username": "alice", "account_type": "human", + "status": "active", "created_at": "2023-11-15T12:00:00Z", + "updated_at": "2023-11-15T12:00:00Z", "totp_enabled": false + } + ]))) + .mount(&server) + .await; + + let c = admin_client(&server).await; + let accounts = c.list_accounts().await.unwrap(); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].username, "alice"); +} + +#[tokio::test] +async fn test_get_account_not_found() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/accounts/missing")) + .respond_with(json_body_status(404, serde_json::json!({"error": "account not found"}))) + .mount(&server) + .await; + + let c = admin_client(&server).await; + let err = c.get_account("missing").await.unwrap_err(); + assert!(matches!(err, MciasError::NotFound(_)), "expected NotFound, got {err:?}"); +} + +#[tokio::test] +async fn test_update_account() { + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path("/v1/accounts/uuid-1")) + .respond_with(json_body(serde_json::json!({ + "id": "uuid-1", "username": "alice", "account_type": "human", + "status": "inactive", "created_at": "2023-11-15T12:00:00Z", + "updated_at": "2023-11-15T13:00:00Z", "totp_enabled": false + }))) + .mount(&server) + .await; + + let c = admin_client(&server).await; + let a = c.update_account("uuid-1", "inactive").await.unwrap(); + assert_eq!(a.status, "inactive"); +} + +#[tokio::test] +async fn test_delete_account() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/accounts/uuid-1")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + let c = admin_client(&server).await; + c.delete_account("uuid-1").await.unwrap(); +} + +// ---- roles ---- + +#[tokio::test] +async fn test_get_set_roles() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/accounts/uuid-1/roles")) + .respond_with(json_body(serde_json::json!(["admin", "viewer"]))) + .mount(&server) + .await; + + Mock::given(method("PUT")) + .and(path("/v1/accounts/uuid-1/roles")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + let c = admin_client(&server).await; + let roles = c.get_roles("uuid-1").await.unwrap(); + assert_eq!(roles, vec!["admin", "viewer"]); + + c.set_roles("uuid-1", &["editor"]).await.unwrap(); +} + +// ---- tokens ---- + +#[tokio::test] +async fn test_issue_service_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/token/issue")) + .respond_with(json_body(serde_json::json!({ + "token": "svc-token", + "expires_at": "2099-01-01T00:00:00Z" + }))) + .mount(&server) + .await; + + let c = admin_client(&server).await; + let (tok, _) = c.issue_service_token("svc-uuid").await.unwrap(); + assert_eq!(tok, "svc-token"); +} + +#[tokio::test] +async fn test_revoke_token() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/token/some-jti")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + let c = admin_client(&server).await; + c.revoke_token("some-jti").await.unwrap(); +} + +// ---- pg creds ---- + +#[tokio::test] +async fn test_pg_creds_not_found() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/accounts/uuid-1/pgcreds")) + .respond_with(json_body_status(404, serde_json::json!({"error": "no pg credentials found"}))) + .mount(&server) + .await; + + let c = admin_client(&server).await; + let err = c.get_pg_creds("uuid-1").await.unwrap_err(); + assert!(matches!(err, MciasError::NotFound(_))); +} + +#[tokio::test] +async fn test_set_get_pg_creds() { + let server = MockServer::start().await; + + Mock::given(method("PUT")) + .and(path("/v1/accounts/uuid-1/pgcreds")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/accounts/uuid-1/pgcreds")) + .respond_with(json_body(serde_json::json!({ + "host": "db.example.com", + "port": 5432, + "database": "mydb", + "username": "dbuser", + "password": "dbpass" + }))) + .mount(&server) + .await; + + let c = admin_client(&server).await; + c.set_pg_creds("uuid-1", "db.example.com", 5432, "mydb", "dbuser", "dbpass") + .await + .unwrap(); + + let creds = c.get_pg_creds("uuid-1").await.unwrap(); + assert_eq!(creds.host, "db.example.com"); + assert_eq!(creds.port, 5432); + assert_eq!(creds.password, "dbpass"); +} + +// ---- error type coverage ---- + +#[tokio::test] +async fn test_forbidden_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/accounts")) + .respond_with(json_body_status(403, serde_json::json!({"error": "forbidden"}))) + .mount(&server) + .await; + + // Use a non-admin token. + let c = Client::new(&server.uri(), ClientOptions { + token: Some("user-token".to_string()), + ..Default::default() + }) + .unwrap(); + let err = c.list_accounts().await.unwrap_err(); + assert!(matches!(err, MciasError::Forbidden(_))); +} + +// ---- integration: login → validate → logout ---- + +#[tokio::test] +async fn test_integration_login_validate_logout() { + let server = MockServer::start().await; + + // Login + Mock::given(method("POST")) + .and(path("/v1/auth/login")) + .respond_with(json_body(serde_json::json!({ + "token": "integration-token", + "expires_at": "2099-01-01T00:00:00Z" + }))) + .mount(&server) + .await; + + // ValidateToken — valid + Mock::given(method("POST")) + .and(path("/v1/token/validate")) + .respond_with(json_body(serde_json::json!({ + "valid": true, + "sub": "alice-uuid", + "roles": [], + "expires_at": "2099-01-01T00:00:00Z" + }))) + .up_to_n_times(1) + .mount(&server) + .await; + + // Logout + Mock::given(method("POST")) + .and(path("/v1/auth/logout")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + // ValidateToken — invalid (after logout, simulated by second mock) + Mock::given(method("POST")) + .and(path("/v1/token/validate")) + .respond_with(json_body(serde_json::json!({"valid": false}))) + .mount(&server) + .await; + + let c = Client::new(&server.uri(), ClientOptions::default()).unwrap(); + + let (tok, _) = c.login("alice", "s3cret", None).await.unwrap(); + assert_eq!(tok, "integration-token"); + + let claims = c.validate_token(&tok).await.unwrap(); + assert!(claims.valid, "token should be valid before logout"); + + c.logout().await.unwrap(); + assert!(c.token().await.is_none(), "token cleared after logout"); + + let claims_after = c.validate_token(&tok).await.unwrap(); + assert!(!claims_after.valid, "token should be invalid after logout"); +} diff --git a/clients/testdata/account_response.json b/clients/testdata/account_response.json new file mode 100644 index 0000000..9af0b9e --- /dev/null +++ b/clients/testdata/account_response.json @@ -0,0 +1,9 @@ +{ + "id": "123e4567-e89b-12d3-a456-426614174000", + "username": "alice", + "account_type": "human", + "status": "active", + "created_at": "2023-11-15T12:00:00Z", + "updated_at": "2023-11-15T12:00:00Z", + "totp_enabled": false +} diff --git a/clients/testdata/accounts_list_response.json b/clients/testdata/accounts_list_response.json new file mode 100644 index 0000000..275f9f6 --- /dev/null +++ b/clients/testdata/accounts_list_response.json @@ -0,0 +1,20 @@ +[ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "username": "alice", + "account_type": "human", + "status": "active", + "created_at": "2023-11-15T12:00:00Z", + "updated_at": "2023-11-15T12:00:00Z", + "totp_enabled": false + }, + { + "id": "223e4567-e89b-12d3-a456-426614174001", + "username": "bot-svc", + "account_type": "system", + "status": "active", + "created_at": "2023-11-15T13:00:00Z", + "updated_at": "2023-11-15T13:00:00Z", + "totp_enabled": false + } +] diff --git a/clients/testdata/error_response.json b/clients/testdata/error_response.json new file mode 100644 index 0000000..664975a --- /dev/null +++ b/clients/testdata/error_response.json @@ -0,0 +1,3 @@ +{ + "error": "invalid credentials" +} diff --git a/clients/testdata/login_response.json b/clients/testdata/login_response.json new file mode 100644 index 0000000..90ce451 --- /dev/null +++ b/clients/testdata/login_response.json @@ -0,0 +1,4 @@ +{ + "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0ZXN0LW1jaWFzIiwic3ViIjoiMTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDAwIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwODY0MDAsImp0aSI6ImFiY2QxMjM0LWFiY2QtMTIzNC1hYmNkLTEyMzRhYmNkMTIzNCIsInJvbGVzIjpbXX0.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "expires_at": "2023-11-16T00:00:00Z" +} diff --git a/clients/testdata/pgcreds_response.json b/clients/testdata/pgcreds_response.json new file mode 100644 index 0000000..dd1b045 --- /dev/null +++ b/clients/testdata/pgcreds_response.json @@ -0,0 +1,7 @@ +{ + "host": "db.example.com", + "port": 5432, + "database": "myapp", + "username": "appuser", + "password": "s3cret" +} diff --git a/clients/testdata/public_key_response.json b/clients/testdata/public_key_response.json new file mode 100644 index 0000000..ea6853c --- /dev/null +++ b/clients/testdata/public_key_response.json @@ -0,0 +1,7 @@ +{ + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "use": "sig", + "alg": "EdDSA" +} diff --git a/clients/testdata/roles_response.json b/clients/testdata/roles_response.json new file mode 100644 index 0000000..5ed4bf0 --- /dev/null +++ b/clients/testdata/roles_response.json @@ -0,0 +1,3 @@ +{ + "roles": ["admin", "developer"] +} diff --git a/clients/testdata/validate_token_response.json b/clients/testdata/validate_token_response.json new file mode 100644 index 0000000..80e29e9 --- /dev/null +++ b/clients/testdata/validate_token_response.json @@ -0,0 +1,6 @@ +{ + "valid": true, + "sub": "123e4567-e89b-12d3-a456-426614174000", + "roles": [], + "expires_at": "2023-11-16T00:00:00Z" +} diff --git a/test/mock/mockserver.go b/test/mock/mockserver.go new file mode 100644 index 0000000..f09dc07 --- /dev/null +++ b/test/mock/mockserver.go @@ -0,0 +1,516 @@ +// Package mock provides an in-memory MCIAS server for integration tests. +// +// Security note: this package is test-only. It never enforces TLS and uses +// trivial token generation. Do not use in production. +package mock +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" +) +// Account holds mock account state. +type Account struct { + ID string + Username string + Password string + AccountType string + Status string + Roles []string +} +// PGCreds holds mock Postgres credential state. +type PGCreds struct { + Host string + Port int + Database string + Username string + Password string +} +// Server is an in-memory MCIAS mock server. +type Server struct { + mu sync.RWMutex + accounts map[string]*Account // id → account + byName map[string]*Account // username → account + tokens map[string]string // token → account id + revoked map[string]bool // revoked tokens + pgcreds map[string]*PGCreds // account id → pg creds + nextSeq int + httpServer *httptest.Server +} +// NewServer creates and starts a new mock server. Call Close() when done. +func NewServer() *Server { + s := &Server{ + accounts: make(map[string]*Account), + byName: make(map[string]*Account), + tokens: make(map[string]string), + revoked: make(map[string]bool), + pgcreds: make(map[string]*PGCreds), + } + mux := http.NewServeMux() + mux.HandleFunc("/v1/health", s.handleHealth) + mux.HandleFunc("/v1/keys/public", s.handlePublicKey) + mux.HandleFunc("/v1/auth/login", s.handleLogin) + mux.HandleFunc("/v1/auth/logout", s.handleLogout) + mux.HandleFunc("/v1/auth/renew", s.handleRenew) + mux.HandleFunc("/v1/token/validate", s.handleValidate) + mux.HandleFunc("/v1/token/issue", s.handleIssueToken) + mux.HandleFunc("/v1/accounts", s.handleAccounts) + mux.HandleFunc("/v1/accounts/", s.handleAccountByID) + s.httpServer = httptest.NewServer(mux) + return s +} +// URL returns the base URL of the mock server. +func (s *Server) URL() string { + return s.httpServer.URL +} +// Close shuts down the mock server. +func (s *Server) Close() { + s.httpServer.Close() +} +// AddAccount adds a test account and returns its ID. +func (s *Server) AddAccount(username, password, accountType string, roles ...string) string { + s.mu.Lock() + defer s.mu.Unlock() + s.nextSeq++ + id := fmt.Sprintf("mock-uuid-%d", s.nextSeq) + acct := &Account{ + ID: id, + Username: username, + Password: password, + AccountType: accountType, + Status: "active", + Roles: append([]string{}, roles...), + } + s.accounts[id] = acct + s.byName[username] = acct + return id +} +// IssueToken directly adds a token for an account (for pre-auth test setup). +func (s *Server) IssueToken(accountID, token string) { + s.mu.Lock() + defer s.mu.Unlock() + s.tokens[token] = accountID +} +// issueToken creates a new token for the given account ID. +// Caller must hold s.mu (write lock). +func (s *Server) issueToken(accountID string) string { + s.nextSeq++ + tok := fmt.Sprintf("mock-token-%d", s.nextSeq) + s.tokens[tok] = accountID + return tok +} +func (s *Server) bearerToken(r *http.Request) string { + auth := r.Header.Get("Authorization") + if len(auth) > 7 && strings.ToLower(auth[:7]) == "bearer " { + return auth[7:] + } + return "" +} +func (s *Server) authenticatedAccount(r *http.Request) *Account { + tok := s.bearerToken(r) + if tok == "" { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.revoked[tok] { + return nil + } + id, ok := s.tokens[tok] + if !ok { + return nil + } + return s.accounts[id] +} +func sendJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} +func sendError(w http.ResponseWriter, status int, msg string) { + sendJSON(w, status, map[string]string{"error": msg}) +} +func (s *Server) accountToMap(a *Account) map[string]interface{} { + return map[string]interface{}{ + "id": a.ID, + "username": a.Username, + "account_type": a.AccountType, + "status": a.Status, + "created_at": "2023-11-15T12:00:00Z", + "updated_at": "2023-11-15T12:00:00Z", + "totp_enabled": false, + } +} +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + sendError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + sendJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} +func (s *Server) handlePublicKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + sendError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + sendJSON(w, http.StatusOK, map[string]string{ + "kty": "OKP", + "crv": "Ed25519", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "use": "sig", + "alg": "EdDSA", + }) +} +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendError(w, http.StatusBadRequest, "bad request") + return + } + s.mu.Lock() + defer s.mu.Unlock() + acct, ok := s.byName[req.Username] + if !ok || acct.Password != req.Password || acct.Status != "active" { + sendError(w, http.StatusUnauthorized, "invalid credentials") + return + } + tok := s.issueToken(acct.ID) + sendJSON(w, http.StatusOK, map[string]string{ + "token": tok, + "expires_at": "2099-01-01T00:00:00Z", + }) +} +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + tok := s.bearerToken(r) + if tok == "" { + sendError(w, http.StatusUnauthorized, "unauthorized") + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.revoked[tok] = true + delete(s.tokens, tok) + w.WriteHeader(http.StatusNoContent) +} +func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + tok := s.bearerToken(r) + if tok == "" { + sendError(w, http.StatusUnauthorized, "unauthorized") + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.revoked[tok] { + sendError(w, http.StatusUnauthorized, "unauthorized") + return + } + aid, ok := s.tokens[tok] + if !ok { + sendError(w, http.StatusUnauthorized, "unauthorized") + return + } + s.revoked[tok] = true + delete(s.tokens, tok) + newTok := s.issueToken(aid) + sendJSON(w, http.StatusOK, map[string]string{ + "token": newTok, + "expires_at": "2099-01-01T00:00:00Z", + }) +} +func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req struct { + Token string `json:"token"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + tok := req.Token + if tok == "" { + tok = s.bearerToken(r) + } + s.mu.RLock() + defer s.mu.RUnlock() + if tok == "" || s.revoked[tok] { + sendJSON(w, http.StatusOK, map[string]interface{}{"valid": false}) + return + } + aid, ok := s.tokens[tok] + if !ok { + sendJSON(w, http.StatusOK, map[string]interface{}{"valid": false}) + return + } + acct := s.accounts[aid] + sendJSON(w, http.StatusOK, map[string]interface{}{ + "valid": true, + "sub": acct.ID, + "roles": acct.Roles, + "expires_at": "2099-01-01T00:00:00Z", + }) +} +func (s *Server) handleIssueToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + acct := s.authenticatedAccount(r) + if acct == nil { + sendError(w, http.StatusUnauthorized, "unauthorized") + return + } + isAdmin := false + for _, role := range acct.Roles { + if role == "admin" { + isAdmin = true + break + } + } + if !isAdmin { + sendError(w, http.StatusForbidden, "forbidden") + return + } + var req struct { + AccountID string `json:"account_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == "" { + sendError(w, http.StatusBadRequest, "bad request") + return + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.accounts[req.AccountID]; !ok { + sendError(w, http.StatusNotFound, "account not found") + return + } + tok := s.issueToken(req.AccountID) + sendJSON(w, http.StatusOK, map[string]string{ + "token": tok, + "expires_at": "2099-01-01T00:00:00Z", + }) +} +func (s *Server) handleAccounts(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if s.requireAdmin(w, r) == nil { + return + } + s.mu.RLock() + list := make([]map[string]interface{}, 0, len(s.accounts)) + for _, a := range s.accounts { + list = append(list, s.accountToMap(a)) + } + s.mu.RUnlock() + sendJSON(w, http.StatusOK, list) + case http.MethodPost: + if s.requireAdmin(w, r) == nil { + return + } + var req struct { + Username string `json:"username"` + AccountType string `json:"account_type"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" { + sendError(w, http.StatusBadRequest, "bad request") + return + } + s.mu.Lock() + if _, exists := s.byName[req.Username]; exists { + s.mu.Unlock() + sendError(w, http.StatusConflict, "username already exists") + return + } + s.nextSeq++ + id := fmt.Sprintf("mock-uuid-%d", s.nextSeq) + newAcct := &Account{ + ID: id, + Username: req.Username, + Password: req.Password, + AccountType: req.AccountType, + Status: "active", + Roles: []string{}, + } + s.accounts[id] = newAcct + s.byName[req.Username] = newAcct + s.mu.Unlock() + sendJSON(w, http.StatusCreated, s.accountToMap(newAcct)) + default: + sendError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} +func (s *Server) handleAccountByID(w http.ResponseWriter, r *http.Request) { + // Parse path: /v1/accounts/{id}[/roles|/pgcreds] + path := strings.TrimPrefix(r.URL.Path, "/v1/accounts/") + parts := strings.SplitN(path, "/", 2) + id := parts[0] + sub := "" + if len(parts) == 2 { + sub = parts[1] + } + switch { + case sub == "roles": + s.handleRoles(w, r, id) + case sub == "pgcreds": + s.handlePGCreds(w, r, id) + case sub == "": + s.handleSingleAccount(w, r, id) + default: + sendError(w, http.StatusNotFound, "not found") + } +} +func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *Account { + acct := s.authenticatedAccount(r) + if acct == nil { + sendError(w, http.StatusUnauthorized, "unauthorized") + return nil + } + for _, role := range acct.Roles { + if role == "admin" { + return acct + } + } + sendError(w, http.StatusForbidden, "forbidden") + return nil +} +func (s *Server) handleSingleAccount(w http.ResponseWriter, r *http.Request, id string) { + if s.requireAdmin(w, r) == nil { + return + } + s.mu.RLock() + acct, ok := s.accounts[id] + s.mu.RUnlock() + if !ok { + sendError(w, http.StatusNotFound, "account not found") + return + } + switch r.Method { + case http.MethodGet: + sendJSON(w, http.StatusOK, s.accountToMap(acct)) + case http.MethodPatch: + var req struct { + Status string `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendError(w, http.StatusBadRequest, "bad request") + return + } + s.mu.Lock() + if req.Status != "" { + acct.Status = req.Status + } + s.mu.Unlock() + sendJSON(w, http.StatusOK, s.accountToMap(acct)) + case http.MethodDelete: + s.mu.Lock() + delete(s.accounts, id) + delete(s.byName, acct.Username) + s.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + default: + sendError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} +func (s *Server) handleRoles(w http.ResponseWriter, r *http.Request, id string) { + if s.requireAdmin(w, r) == nil { + return + } + s.mu.RLock() + acct, ok := s.accounts[id] + s.mu.RUnlock() + if !ok { + sendError(w, http.StatusNotFound, "account not found") + return + } + switch r.Method { + case http.MethodGet: + s.mu.RLock() + roles := append([]string{}, acct.Roles...) + s.mu.RUnlock() + sendJSON(w, http.StatusOK, map[string]interface{}{"roles": roles}) + case http.MethodPut: + var req struct { + Roles []string `json:"roles"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendError(w, http.StatusBadRequest, "bad request") + return + } + s.mu.Lock() + acct.Roles = req.Roles + s.mu.Unlock() + sendJSON(w, http.StatusOK, map[string]interface{}{"roles": req.Roles}) + default: + sendError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} +func (s *Server) handlePGCreds(w http.ResponseWriter, r *http.Request, id string) { + if s.requireAdmin(w, r) == nil { + return + } + s.mu.RLock() + _, ok := s.accounts[id] + s.mu.RUnlock() + if !ok { + sendError(w, http.StatusNotFound, "account not found") + return + } + switch r.Method { + case http.MethodGet: + s.mu.RLock() + creds, hasCreds := s.pgcreds[id] + s.mu.RUnlock() + if !hasCreds { + sendError(w, http.StatusNotFound, "no pg credentials") + return + } + sendJSON(w, http.StatusOK, map[string]interface{}{ + "host": creds.Host, + "port": creds.Port, + "database": creds.Database, + "username": creds.Username, + "password": creds.Password, + }) + case http.MethodPut: + var req struct { + Host string `json:"host"` + Port int `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendError(w, http.StatusBadRequest, "bad request") + return + } + s.mu.Lock() + s.pgcreds[id] = &PGCreds{ + Host: req.Host, + Port: req.Port, + Database: req.Database, + Username: req.Username, + Password: req.Password, + } + s.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + default: + sendError(w, http.StatusMethodNotAllowed, "method not allowed") + } +}