Files
mcias/INTEGRATION.md
Kyle Isom d42f51fc83 Fix F-02: replace password-in-hidden-field with nonce
- ui/ui.go: add pendingLogin struct and pendingLogins sync.Map
  to UIServer; add issueTOTPNonce (generates 128-bit random nonce,
  stores accountID with 90s TTL) and consumeTOTPNonce (single-use,
  expiry-checked LoadAndDelete); add dummyHash() method
- ui/handlers_auth.go: split handleLoginPost into step 1
  (password verify → issue nonce) and step 2 (handleTOTPStep,
  consume nonce → validate TOTP) via a new finishLogin helper;
  password never transmitted or stored after step 1
- ui/ui_test.go: refactor newTestMux to reuse new
  newTestUIServer; add TestTOTPNonceIssuedAndConsumed,
  TestTOTPNonceUnknownRejected, TestTOTPNonceExpired, and
  TestLoginPostPasswordNotInTOTPForm; 11/11 tests pass
- web/templates/fragments/totp_step.html: replace
  'name=password' hidden field with 'name=totp_nonce'
- db/accounts.go: add GetAccountByID for TOTP step lookup
- AUDIT.md: mark F-02 as fixed
Security: the plaintext password previously survived two HTTP
  round-trips and lived in the browser DOM during the TOTP step.
  The nonce approach means the password is verified once and
  immediately discarded; only an opaque random token tied to an
  account ID (never a credential) crosses the wire on step 2.
  Nonces are single-use and expire after 90 seconds to limit
  the window if one is captured.
2026-03-11 20:33:04 -07:00

18 KiB

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
  2. Quick Start — Human Login
  3. Quick Start — System Account
  4. Verifying a Token in Your Service
  5. TOTP (Two-Factor Authentication)
  6. Setting Up a System Account
  7. Role-Based Authorization
  8. Client Libraries
  9. Token Lifecycle
  10. Error Reference
  11. API Reference
  12. 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

# 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.

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.

# 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.

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:

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):

{
  "kty": "OKP",
  "crv": "Ed25519",
  "use": "sig",
  "alg": "EdDSA",
  "x": "<base64url-encoded public key>"
}

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 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 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 <token>

or equivalently with a JSON body:

{ "token": "<JWT>" }

Response:

{
  "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 <token>

Response:

{
  "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 <token>
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 <admin-token>
Content-Type: application/json

{ "account_id": "<UUID>" }

Use this for account recovery when a user loses their TOTP device.


Setting Up a System Account

Full walkthrough using mciasctl:

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 <UUID> -roles readonly

# 3. Issue the service token
mciasctl token issue -id <UUID>
# 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 <UUID>
# 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

# Grant roles (replaces existing)
mciasctl role set -id <UUID> -roles editor,readonly

# Or via the API
curl -sS -X PUT https://auth.example.com:8443/v1/accounts/<UUID>/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:

{
  "sub": "550e8400-...",
  "roles": ["editor", "readonly"],
  "exp": 1775000000,
  "iss": "https://auth.example.com"
}
def require_role(payload: dict, role: str) -> None:
    if role not in payload.get("roles", []):
        raise PermissionError(f"role '{role}' required")
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

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

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):

{ "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 for the machine-readable OpenAPI 3.1 spec. A Swagger UI is served at https://<server>/docs when the server is running.

Authentication header

All authenticated endpoints require:

Authorization: Bearer <JWT>

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.