# MCIAS Integration Guide MCIAS is an HTTP+TLS authentication service. It issues Ed25519-signed JWTs and provides account, role, and credential management. This guide covers everything needed to integrate a new system with MCIAS as its authentication backend. --- ## Contents 1. [Concepts](#concepts) 2. [Quick Start — Human Login](#quick-start--human-login) 3. [Quick Start — System Account](#quick-start--system-account) 4. [Verifying a Token in Your Service](#verifying-a-token-in-your-service) 5. [TOTP (Two-Factor Authentication)](#totp-two-factor-authentication) 6. [Setting Up a System Account](#setting-up-a-system-account) 7. [Role-Based Authorization](#role-based-authorization) 8. [Client Libraries](#client-libraries) 9. [Token Lifecycle](#token-lifecycle) 10. [Error Reference](#error-reference) 11. [API Reference](#api-reference) 12. [Security Notes for Integrators](#security-notes-for-integrators) --- ## Concepts **Human account** — A person who logs in with username + password (+ optional TOTP). The server returns a JWT valid for 30 days (8 hours for admins). The token is bearer-only; there are no cookies or server-side sessions. **System account** — A non-interactive identity for a service, daemon, or automated process. It has no password. An admin issues a long-lived bearer token (1 year) that the service stores and sends with every request. **JWT** — All tokens are signed with Ed25519 (algorithm `EdDSA`). They contain `sub` (account UUID), `roles`, `iss`, `iat`, `exp`, and `jti`. Tokens can be validated offline using the public key, or online via `/v1/token/validate`. **Role** — An arbitrary string scoped to your deployment (e.g. `admin`, `editor`, `readonly`). MCIAS enforces `admin` internally; your services enforce any other roles by inspecting the `roles` claim. --- ## Quick Start — Human Login ```sh # Login RESPONSE=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/login \ --cacert /etc/mcias/server.crt \ -H 'Content-Type: application/json' \ -d '{"username":"alice","password":"s3cr3t"}') TOKEN=$(echo "$RESPONSE" | jq -r .token) EXPIRES=$(echo "$RESPONSE" | jq -r .expires_at) # Use the token curl -sS https://my-app.example.com/api/resource \ -H "Authorization: Bearer $TOKEN" # Renew before expiry (extends the window, invalidates the old token) NEW_TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/renew \ --cacert /etc/mcias/server.crt \ -H "Authorization: Bearer $TOKEN" | jq -r .token) # Logout (revokes the token immediately) curl -sS -X POST https://auth.example.com:8443/v1/auth/logout \ --cacert /etc/mcias/server.crt \ -H "Authorization: Bearer $TOKEN" ``` ### Login with TOTP If the user has enrolled TOTP, include the current 6-digit code. Omitting it returns HTTP 401 with code `totp_required`. ```sh curl -sS -X POST https://auth.example.com:8443/v1/auth/login \ --cacert /etc/mcias/server.crt \ -H 'Content-Type: application/json' \ -d '{"username":"alice","password":"s3cr3t","totp_code":"123456"}' ``` --- ## Quick Start — System Account A system account authenticates non-interactively. An admin creates it, issues a token, and the service includes that token in every request. ```sh # 1. Admin: create the system account TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/login \ --cacert /etc/mcias/server.crt \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"..."}' | jq -r .token) ACCOUNT=$(curl -sS -X POST https://auth.example.com:8443/v1/accounts \ --cacert /etc/mcias/server.crt \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"username":"my-service","account_type":"system"}') SVC_UUID=$(echo "$ACCOUNT" | jq -r .id) # 2. Admin: issue a bearer token for the service (valid 1 year) SVC_TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/token/issue \ --cacert /etc/mcias/server.crt \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"account_id\":\"$SVC_UUID\"}" | jq -r .token) # 3. Store SVC_TOKEN in the service's secret store (env var, Vault, etc.) # 4. Service: use it in every outbound or MCIAS API call curl -sS https://protected-api.example.com/v1/resource \ -H "Authorization: Bearer $SVC_TOKEN" ``` To rotate the token (e.g. after exposure), reissue it. The old token is automatically revoked. ```sh curl -sS -X POST https://auth.example.com:8443/v1/token/issue \ --cacert /etc/mcias/server.crt \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"account_id\":\"$SVC_UUID\"}" ``` --- ## Verifying a Token in Your Service Your service receives a Bearer token from a client. It must verify the token before trusting any claims. There are two approaches: ### Option A: Offline verification (recommended) Fetch the public key once at startup and cache it. Verify the JWT locally without a round-trip to MCIAS. ``` GET /v1/keys/public ``` Response (JWK, RFC 8037): ```json { "kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA", "x": "" } ``` Verification steps your code must perform (in order): 1. **Check `alg` header** — must be exactly `"EdDSA"`. Reject `"none"` and all other values before touching the signature. 2. **Verify the Ed25519 signature** using the public key. 3. **Validate claims:** `exp` > now, `iat` <= now, `iss` matches your configured issuer. 4. **Check for revocation** — if your service needs real-time revocation, call `/v1/token/validate` instead (Option B). Offline verification will accept a token until it expires, even if it was explicitly revoked. ```python # Python example using PyJWT import jwt # pip install PyJWT[crypto] PUBLIC_KEY = load_ed25519_jwk(mcias_client.get_public_key()) def verify_token(bearer: str) -> dict: token = bearer.removeprefix("Bearer ").strip() # Step 1 is performed by PyJWT when algorithms=["EdDSA"] is specified. payload = jwt.decode( token, PUBLIC_KEY, algorithms=["EdDSA"], issuer="https://auth.example.com", options={"require": ["exp", "iat", "iss", "sub", "jti"]}, ) return payload # {"sub": "...", "roles": [...], "exp": ..., "jti": "..."} ``` ```go // Go example using golang-jwt/jwt/v5 import jwtlib "github.com/golang-jwt/jwt/v5" func verifyToken(tokenStr string, pubKey ed25519.PublicKey, issuer string) (*Claims, error) { parsed, err := jwtlib.ParseWithClaims(tokenStr, &Claims{}, func(t *jwtlib.Token) (any, error) { // Step 1: algorithm must be EdDSA if _, ok := t.Method.(*jwtlib.SigningMethodEd25519); !ok { return nil, fmt.Errorf("unexpected alg: %v", t.Header["alg"]) } return pubKey, nil }, jwtlib.WithIssuedAt(), jwtlib.WithIssuer(issuer), ) if err != nil { return nil, err } return parsed.Claims.(*Claims), nil } ``` ### Option B: Online validation Call MCIAS for every request. This reflects revocations immediately but adds latency. Suitable for high-security paths. ``` POST /v1/token/validate Authorization: Bearer ``` or equivalently with a JSON body: ```json { "token": "" } ``` Response: ```json { "valid": true, "sub": "550e8400-e29b-41d4-a716-446655440000", "roles": ["editor"], "expires_at": "2026-04-10T12:34:56Z" } ``` On failure: `{ "valid": false }` (HTTP 200, never 401). Do not branch on the HTTP status code; always inspect `valid`. --- ## TOTP (Two-Factor Authentication) TOTP enrollment is a two-step process and must be completed in one session. Abandoning after step 1 does **not** lock the account. ### Step 1: Enroll ``` POST /v1/auth/totp/enroll Authorization: Bearer ``` Response: ```json { "secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS" } ``` Show the `otpauth_uri` as a QR code (e.g. using a TOTP app like Authenticator). The secret is shown **once**; it is not retrievable after this call. ### Step 2: Confirm ``` POST /v1/auth/totp/confirm Authorization: Bearer Content-Type: application/json { "code": "123456" } ``` HTTP 204 on success. After this call, all future logins for this account require a TOTP code. ### Admin: Remove TOTP ``` DELETE /v1/auth/totp Authorization: Bearer Content-Type: application/json { "account_id": "" } ``` Use this for account recovery when a user loses their TOTP device. --- ## Setting Up a System Account Full walkthrough using `mciasctl`: ```sh export MCIAS_TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/login \ --cacert /etc/mcias/server.crt \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"..."}' | jq -r .token) # 1. Create the account mciasctl -server https://auth.example.com:8443 \ account create -username my-service -type system # 2. Note the UUID printed, then assign roles mciasctl role set -id -roles readonly # 3. Issue the service token mciasctl token issue -id # Prints: token=eyJ... expires_at=2027-03-11T... # 4. Store the token securely (e.g. in a Kubernetes secret, Vault, .env) echo "MCIAS_SVC_TOKEN=eyJ..." >> /etc/my-service/env # 5. Rotate (when token is compromised or at scheduled intervals) mciasctl token issue -id # New token issued; old token automatically revoked ``` --- ## Role-Based Authorization Roles are arbitrary strings. MCIAS enforces only `admin` internally. Your services enforce any other roles by inspecting the `roles` JWT claim. ### Assigning roles ```sh # Grant roles (replaces existing) mciasctl role set -id -roles editor,readonly # Or via the API curl -sS -X PUT https://auth.example.com:8443/v1/accounts//roles \ --cacert /etc/mcias/server.crt \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"roles":["editor","readonly"]}' ``` ### Checking roles in your service Roles are in the `roles` claim of the decoded JWT: ```json { "sub": "550e8400-...", "roles": ["editor", "readonly"], "exp": 1775000000, "iss": "https://auth.example.com" } ``` ```python def require_role(payload: dict, role: str) -> None: if role not in payload.get("roles", []): raise PermissionError(f"role '{role}' required") ``` ```go func hasRole(claims *Claims, role string) bool { for _, r := range claims.Roles { if r == role { return true } } return false } ``` --- ## Client Libraries Four language implementations are available in the `clients/` directory. All expose the same API surface: | Language | Location | Install | |----------|----------|---------| | Go | `clients/go/` | `go get git.wntrmute.dev/kyle/mcias/clients/go` | | Python | `clients/python/` | `pip install ./clients/python` | | Rust | `clients/rust/` | `cargo add mcias-client` | | Common Lisp | `clients/lisp/` | ASDF `mcias-client` | ### Go ```go import mcias "git.wntrmute.dev/kyle/mcias/clients/go" c, err := mcias.New("https://auth.example.com:8443", "/etc/mcias/server.crt", "") if err != nil { ... } token, expiresAt, err := c.Login("alice", "s3cr3t", "") if err != nil { ... } result, err := c.ValidateToken(token) if err != nil || !result.Valid { ... } fmt.Println(result.Sub, result.Roles) ``` ### Python ```python from mcias_client import Client, MciasAuthError c = Client("https://auth.example.com:8443", ca_cert="/etc/mcias/server.crt") token, expires_at = c.login("alice", "s3cr3t") try: result = c.validate_token(token) if not result.valid: raise MciasAuthError("token invalid") print(result.sub, result.roles) except MciasAuthError as e: print("auth error:", e) ``` ### Error types (all languages) | Name | HTTP | 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 | | `MciasTransportError` | — | Network or TLS failure | --- ## Token Lifecycle ``` ┌─────────────────────────────────┐ │ MCIAS │ POST /v1/auth/login │ │ User ─────────────────────► Argon2id verify │ ◄─────────────────── │ Ed25519 sign JWT │ JWT (30d / 8h) │ Track JTI in DB │ │ │ Service ──► POST /v1/token/validate ──► Check sig + JTI │ ◄── {valid, sub, roles} │ │ │ User ──► POST /v1/auth/renew ─────────► Revoke old JTI │ ◄── New JWT Issue new JWT │ │ │ User ──► POST /v1/auth/logout ────────► Revoke JTI │ │ │ └─────────────────────────────────┘ ``` **Expiry defaults:** | Account type | Default expiry | Notes | |---|---|---| | Human | 30 days | Configurable via `tokens.default_expiry` | | Admin (`admin` role) | 8 hours | Configurable via `tokens.admin_expiry` | | System account | 1 year | Configurable via `tokens.service_expiry` | Tokens are revoked on explicit logout, account deletion, or admin revocation. Expired-but-not-revoked tokens can be pruned from the database with `mciasdb prune tokens`. --- ## Error Reference All error responses are JSON with `error` (human-readable) and `code` (machine-readable): ```json { "error": "invalid credentials", "code": "unauthorized" } ``` | HTTP | `code` | When | |------|--------|------| | 400 | `bad_request` | Missing required field or malformed JSON | | 401 | `unauthorized` | Wrong credentials, expired or invalid token | | 401 | `totp_required` | Correct password but TOTP code not provided | | 403 | `forbidden` | Token valid but lacks required role | | 404 | `not_found` | Account or token does not exist | | 409 | `conflict` | Username already taken | | 429 | `rate_limited` | Too many login or validate requests | | 500 | `internal_error` | Unexpected server error | --- ## API Reference See [openapi.yaml](openapi.yaml) for the machine-readable OpenAPI 3.1 spec. A Swagger UI is served at `https:///docs` when the server is running. ### Authentication header All authenticated endpoints require: ``` Authorization: Bearer ``` ### Endpoints summary #### Public (no auth) | Method | Path | Description | |--------|------|-------------| | GET | `/v1/health` | Health check | | GET | `/v1/keys/public` | Ed25519 public key (JWK) | | POST | `/v1/auth/login` | Login; returns JWT | | POST | `/v1/token/validate` | Validate a JWT | #### Authenticated | Method | Path | Description | |--------|------|-------------| | POST | `/v1/auth/logout` | Revoke current token | | POST | `/v1/auth/renew` | Exchange token for a fresh one | | POST | `/v1/auth/totp/enroll` | Begin TOTP enrollment | | POST | `/v1/auth/totp/confirm` | Confirm TOTP enrollment | #### Admin only (`admin` role required) | Method | Path | Description | |--------|------|-------------| | GET | `/v1/accounts` | List all accounts | | POST | `/v1/accounts` | Create account | | GET | `/v1/accounts/{id}` | Get account | | PATCH | `/v1/accounts/{id}` | Update account status | | DELETE | `/v1/accounts/{id}` | Soft-delete account | | GET | `/v1/accounts/{id}/roles` | Get roles | | PUT | `/v1/accounts/{id}/roles` | Replace roles | | GET | `/v1/accounts/{id}/pgcreds` | Get Postgres credentials | | PUT | `/v1/accounts/{id}/pgcreds` | Set Postgres credentials | | POST | `/v1/token/issue` | Issue service account token | | DELETE | `/v1/token/{jti}` | Revoke token by JTI | | DELETE | `/v1/auth/totp` | Remove TOTP from account | | GET | `/v1/audit` | Query audit log | --- ## Security Notes for Integrators **Always validate `alg` before the signature.** Never use a JWT library that accepts `alg: none` or falls back to HMAC. MCIAS only issues `EdDSA` tokens. **Cache the public key, but refresh it.** Fetch `/v1/keys/public` at startup. Refresh it if signature verification starts failing (key rotation). Do not hard-code the key bytes. **Use the CA certificate.** MCIAS typically runs with a self-signed or private-CA cert. Pass the cert path to your HTTP client; do not disable TLS verification. **Do not log tokens.** Strip the `Authorization` header from access logs. A leaked token is valid until it expires or is explicitly revoked. **For revocation-sensitive paths, use online validation.** The `/v1/token/validate` endpoint reflects revocations instantly. Offline verification is fine for low-sensitivity reads; use online validation for writes, privilege escalation, and any action with irreversible effects. **System accounts have one active token at a time.** Issuing a new token via `/v1/token/issue` revokes the previous one. Do not share a system account across services that rotate independently; create one system account per service. **Roles are not hierarchical.** `admin` does not imply any other role and vice versa. Check each required role explicitly.