- 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.
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
- Concepts
- Quick Start — Human Login
- Quick Start — System Account
- Verifying a Token in Your Service
- TOTP (Two-Factor Authentication)
- Setting Up a System Account
- Role-Based Authorization
- Client Libraries
- Token Lifecycle
- Error Reference
- API Reference
- 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:
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):
{
"kty": "OKP",
"crv": "Ed25519",
"use": "sig",
"alg": "EdDSA",
"x": "<base64url-encoded public key>"
}
Verification steps your code must perform (in order):
- Check
algheader — must be exactly"EdDSA". Reject"none"and all other values before touching the signature. - Verify the Ed25519 signature using the public key.
- Validate claims:
exp> now,iat<= now,issmatches your configured issuer. - Check for revocation — if your service needs real-time revocation, call
/v1/token/validateinstead (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.