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