Files
mcias/openapi.yaml
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

966 lines
29 KiB
YAML

openapi: "3.1.0"
info:
title: MCIAS Authentication API
version: "1.0"
description: |
MCIAS (Metacircular Identity and Access System) provides JWT-based
authentication, account management, TOTP, and Postgres credential storage.
All tokens are Ed25519-signed JWTs (algorithm `EdDSA`). Bearer tokens must
be sent in the `Authorization` header as `Bearer <token>`.
Rate limiting applies to `/v1/auth/login` and `/v1/token/validate`:
10 requests per second per IP, burst of 10.
servers:
- url: https://auth.example.com:8443
description: Production
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Error:
type: object
required: [error, code]
properties:
error:
type: string
description: Human-readable error message.
example: invalid credentials
code:
type: string
description: Machine-readable error code.
example: unauthorized
TokenResponse:
type: object
required: [token, expires_at]
properties:
token:
type: string
description: Ed25519-signed JWT (EdDSA).
example: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...
expires_at:
type: string
format: date-time
description: Token expiry in RFC 3339 format.
example: "2026-04-10T12:34:56Z"
Account:
type: object
required: [id, username, account_type, status, created_at, updated_at, totp_enabled]
properties:
id:
type: string
format: uuid
description: Account UUID (use this in all API calls).
example: 550e8400-e29b-41d4-a716-446655440000
username:
type: string
example: alice
account_type:
type: string
enum: [human, system]
example: human
status:
type: string
enum: [active, inactive, deleted]
example: active
created_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
updated_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
totp_enabled:
type: boolean
description: Whether TOTP is enrolled and required for this account.
example: false
AuditEvent:
type: object
required: [id, event_type, event_time, ip_address]
properties:
id:
type: integer
example: 42
event_type:
type: string
example: login_ok
event_time:
type: string
format: date-time
example: "2026-03-11T09:01:23Z"
actor_id:
type: string
format: uuid
nullable: true
description: UUID of the account that performed the action. Null for bootstrap events.
example: 550e8400-e29b-41d4-a716-446655440000
target_id:
type: string
format: uuid
nullable: true
description: UUID of the affected account, if applicable.
ip_address:
type: string
example: "192.0.2.1"
details:
type: string
description: JSON blob with event-specific metadata. Never contains credentials.
example: '{"jti":"f47ac10b-..."}'
PGCreds:
type: object
required: [host, port, database, username, password]
properties:
host:
type: string
example: db.example.com
port:
type: integer
example: 5432
database:
type: string
example: mydb
username:
type: string
example: myuser
password:
type: string
description: >
Plaintext password (sent over TLS, stored encrypted at rest with
AES-256-GCM). Only returned to admin callers.
example: hunter2
responses:
Unauthorized:
description: Token missing, invalid, expired, or credentials incorrect.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: invalid credentials
code: unauthorized
Forbidden:
description: Token valid but lacks the required role.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: forbidden
code: forbidden
NotFound:
description: Requested resource does not exist.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: account not found
code: not_found
BadRequest:
description: Malformed request or missing required fields.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: username and password are required
code: bad_request
RateLimited:
description: Rate limit exceeded.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: rate limit exceeded
code: rate_limited
paths:
# ── Public ────────────────────────────────────────────────────────────────
/v1/health:
get:
summary: Health check
description: Returns `{"status":"ok"}` if the server is running. No auth required.
operationId: getHealth
tags: [Public]
responses:
"200":
description: Server is healthy.
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: ok
/v1/keys/public:
get:
summary: Ed25519 public key (JWK)
description: |
Returns the server's Ed25519 public key in JWK format (RFC 8037).
Relying parties use this to verify JWT signatures offline.
Cache this key at startup. Refresh it if signature verification begins
failing (indicates key rotation).
**Important:** Always validate the `alg` header of the JWT (`EdDSA`)
before calling the signature verification routine. Never accept `none`.
operationId: getPublicKey
tags: [Public]
responses:
"200":
description: Ed25519 public key in JWK format.
content:
application/json:
schema:
type: object
required: [kty, crv, use, alg, x]
properties:
kty:
type: string
example: OKP
crv:
type: string
example: Ed25519
use:
type: string
example: sig
alg:
type: string
example: EdDSA
x:
type: string
description: Base64url-encoded public key bytes.
example: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo
/v1/auth/login:
post:
summary: Login
description: |
Authenticate with username + password and optionally a TOTP code.
Returns an Ed25519-signed JWT.
Rate limited to 10 requests per second per IP (burst 10).
Error responses always use the generic message `"invalid credentials"`
regardless of whether the user exists, the password is wrong, or the
account is inactive. This prevents user enumeration.
If the account has TOTP enrolled, `totp_code` is required.
Omitting it returns HTTP 401 with code `totp_required`.
operationId: login
tags: [Public]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [username, password]
properties:
username:
type: string
example: alice
password:
type: string
example: s3cr3t
totp_code:
type: string
description: Current 6-digit TOTP code. Required if TOTP is enrolled.
example: "123456"
responses:
"200":
description: Login successful. Returns JWT and expiry.
content:
application/json:
schema:
$ref: "#/components/schemas/TokenResponse"
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Invalid credentials, inactive account, or missing TOTP code.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
examples:
invalid_credentials:
value: {error: invalid credentials, code: unauthorized}
totp_required:
value: {error: TOTP code required, code: totp_required}
"429":
$ref: "#/components/responses/RateLimited"
/v1/token/validate:
post:
summary: Validate a JWT
description: |
Validate a JWT and return its claims. Reflects revocations immediately
(online validation). Use this for high-security paths where offline
verification is insufficient.
The token may be supplied either as a Bearer header or in the JSON body.
**Always inspect the `valid` field.** The response is always HTTP 200;
do not branch on the status code.
Rate limited to 10 requests per second per IP (burst 10).
operationId: validateToken
tags: [Public]
security:
- bearerAuth: []
- {}
requestBody:
description: Optionally supply the token in the body instead of the header.
required: false
content:
application/json:
schema:
type: object
properties:
token:
type: string
example: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...
responses:
"200":
description: Validation result. Always HTTP 200; check `valid`.
content:
application/json:
schema:
type: object
required: [valid]
properties:
valid:
type: boolean
sub:
type: string
format: uuid
description: Subject (account UUID). Present when valid=true.
example: 550e8400-e29b-41d4-a716-446655440000
roles:
type: array
items:
type: string
description: Role list. Present when valid=true.
example: [editor]
expires_at:
type: string
format: date-time
description: Expiry. Present when valid=true.
example: "2026-04-10T12:34:56Z"
examples:
valid:
value: {valid: true, sub: "550e8400-...", roles: [editor], expires_at: "2026-04-10T12:34:56Z"}
invalid:
value: {valid: false}
"429":
$ref: "#/components/responses/RateLimited"
# ── Authenticated ──────────────────────────────────────────────────────────
/v1/auth/logout:
post:
summary: Logout
description: |
Revoke the current bearer token immediately. The JTI is recorded in the
revocation table; subsequent validation calls will return `valid=false`.
operationId: logout
tags: [Auth]
security:
- bearerAuth: []
responses:
"204":
description: Token revoked.
"401":
$ref: "#/components/responses/Unauthorized"
/v1/auth/renew:
post:
summary: Renew token
description: |
Exchange the current token for a fresh one. The old token is revoked.
The new token reflects any role changes made since the original login.
Token expiry is recalculated: 30 days for regular users, 8 hours for
admins.
operationId: renewToken
tags: [Auth]
security:
- bearerAuth: []
responses:
"200":
description: New token issued. Old token revoked.
content:
application/json:
schema:
$ref: "#/components/schemas/TokenResponse"
"401":
$ref: "#/components/responses/Unauthorized"
/v1/auth/totp/enroll:
post:
summary: Begin TOTP enrollment
description: |
Generate a TOTP secret for the authenticated account and return it as a
bare secret and as an `otpauth://` URI (scan with any authenticator app).
The secret is shown **once**. It is stored encrypted at rest and is not
retrievable after this call.
TOTP is not required until the enrollment is confirmed via
`POST /v1/auth/totp/confirm`. Abandoning after this call does not lock
the account.
operationId: enrollTOTP
tags: [Auth]
security:
- bearerAuth: []
responses:
"200":
description: TOTP secret generated.
content:
application/json:
schema:
type: object
required: [secret, otpauth_uri]
properties:
secret:
type: string
description: Base32-encoded TOTP secret. Store in an authenticator app.
example: JBSWY3DPEHPK3PXP
otpauth_uri:
type: string
description: Standard otpauth URI for QR-code generation.
example: "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"
"401":
$ref: "#/components/responses/Unauthorized"
/v1/auth/totp/confirm:
post:
summary: Confirm TOTP enrollment
description: |
Verify the provided TOTP code against the pending secret. On success,
TOTP becomes required for all future logins for this account.
operationId: confirmTOTP
tags: [Auth]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code:
type: string
description: Current 6-digit TOTP code.
example: "123456"
responses:
"204":
description: TOTP confirmed. Required for future logins.
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
# ── Admin ──────────────────────────────────────────────────────────────────
/v1/auth/totp:
delete:
summary: Remove TOTP from account (admin)
description: |
Clear TOTP enrollment for an account. Use for account recovery when a
user loses their TOTP device. The account can log in with password only
after this call.
operationId: removeTOTP
tags: [Admin — Auth]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [account_id]
properties:
account_id:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
responses:
"204":
description: TOTP removed.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/token/issue:
post:
summary: Issue service account token (admin)
description: |
Issue a long-lived bearer token for a system account. If the account
already has an active token, it is revoked and replaced.
Only one active token exists per system account at a time.
Issued tokens expire after 1 year (configurable via
`tokens.service_expiry`).
operationId: issueServiceToken
tags: [Admin — Tokens]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [account_id]
properties:
account_id:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
responses:
"200":
description: Token issued.
content:
application/json:
schema:
$ref: "#/components/schemas/TokenResponse"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/token/{jti}:
delete:
summary: Revoke token by JTI (admin)
description: |
Revoke any token by its JWT ID (`jti` claim). The token is immediately
invalid for all future validation calls.
operationId: revokeToken
tags: [Admin — Tokens]
security:
- bearerAuth: []
parameters:
- name: jti
in: path
required: true
schema:
type: string
format: uuid
example: f47ac10b-58cc-4372-a567-0e02b2c3d479
responses:
"204":
description: Token revoked.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts:
get:
summary: List accounts (admin)
operationId: listAccounts
tags: [Admin — Accounts]
security:
- bearerAuth: []
responses:
"200":
description: Array of accounts.
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Account"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
post:
summary: Create account (admin)
description: |
Create a human or system account.
- `human` accounts require a `password`.
- `system` accounts must not include a `password`; authenticate via
tokens issued by `POST /v1/token/issue`.
operationId: createAccount
tags: [Admin — Accounts]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [username, account_type]
properties:
username:
type: string
example: alice
account_type:
type: string
enum: [human, system]
example: human
password:
type: string
description: Required for human accounts. Hashed with Argon2id.
example: s3cr3t
responses:
"201":
description: Account created.
content:
application/json:
schema:
$ref: "#/components/schemas/Account"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"409":
description: Username already taken.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: username already exists
code: conflict
/v1/accounts/{id}:
parameters:
- name: id
in: path
required: true
description: Account UUID.
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
get:
summary: Get account (admin)
operationId: getAccount
tags: [Admin — Accounts]
security:
- bearerAuth: []
responses:
"200":
description: Account details.
content:
application/json:
schema:
$ref: "#/components/schemas/Account"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
patch:
summary: Update account (admin)
description: Update mutable account fields. Currently only `status` is patchable.
operationId: updateAccount
tags: [Admin — Accounts]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [active, inactive]
example: inactive
responses:
"204":
description: Account updated.
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
delete:
summary: Delete account (admin)
description: |
Soft-delete an account. Sets status to `deleted` and revokes all active
tokens. The account record is retained for audit purposes.
operationId: deleteAccount
tags: [Admin — Accounts]
security:
- bearerAuth: []
responses:
"204":
description: Account deleted.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts/{id}/roles:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
get:
summary: Get account roles (admin)
operationId: getRoles
tags: [Admin — Accounts]
security:
- bearerAuth: []
responses:
"200":
description: Current role list.
content:
application/json:
schema:
type: object
required: [roles]
properties:
roles:
type: array
items:
type: string
example: [editor, readonly]
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
put:
summary: Set account roles (admin)
description: |
Replace the account's full role list. Roles take effect in the **next**
token issued or renewed; existing tokens continue to carry the roles
embedded at issuance time.
operationId: setRoles
tags: [Admin — Accounts]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [roles]
properties:
roles:
type: array
items:
type: string
example: [editor, readonly]
responses:
"204":
description: Roles updated.
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts/{id}/pgcreds:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
get:
summary: Get Postgres credentials (admin)
description: |
Retrieve stored Postgres connection credentials. Password is returned
in plaintext over TLS. Stored encrypted at rest with AES-256-GCM.
operationId: getPGCreds
tags: [Admin — Credentials]
security:
- bearerAuth: []
responses:
"200":
description: Postgres credentials.
content:
application/json:
schema:
$ref: "#/components/schemas/PGCreds"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
put:
summary: Set Postgres credentials (admin)
description: Store or replace Postgres credentials for an account.
operationId: setPGCreds
tags: [Admin — Credentials]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PGCreds"
responses:
"204":
description: Credentials stored.
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/audit:
get:
summary: Query audit log (admin)
description: |
Retrieve audit log entries, newest first. Supports pagination and
filtering. The log is append-only and never contains credentials.
Event types include: `login_ok`, `login_fail`, `login_totp_fail`,
`token_issued`, `token_renewed`, `token_revoked`, `token_expired`,
`account_created`, `account_updated`, `account_deleted`,
`role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`,
`pgcred_accessed`, `pgcred_updated`.
operationId: listAudit
tags: [Admin — Audit]
security:
- bearerAuth: []
parameters:
- name: limit
in: query
schema:
type: integer
default: 50
minimum: 1
maximum: 1000
example: 50
- name: offset
in: query
schema:
type: integer
default: 0
example: 0
- name: event_type
in: query
schema:
type: string
description: Filter by event type.
example: login_fail
- name: actor_id
in: query
schema:
type: string
format: uuid
description: Filter by actor account UUID.
example: 550e8400-e29b-41d4-a716-446655440000
responses:
"200":
description: Paginated audit log.
content:
application/json:
schema:
type: object
required: [events, total, limit, offset]
properties:
events:
type: array
items:
$ref: "#/components/schemas/AuditEvent"
total:
type: integer
description: Total number of matching events (for pagination).
example: 142
limit:
type: integer
example: 50
offset:
type: integer
example: 0
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
tags:
- name: Public
description: No authentication required.
- name: Auth
description: Requires a valid bearer token.
- name: Admin — Auth
description: Requires admin role.
- name: Admin — Tokens
description: Requires admin role.
- name: Admin — Accounts
description: Requires admin role.
- name: Admin — Credentials
description: Requires admin role.
- name: Admin — Audit
description: Requires admin role.