iam-service — API Contracts
Catalog · 05 API Design · DOMAIN_MODEL · Standards · ERROR_CODES
All endpoints are versioned /api/v1/. Auth-related endpoints share the prefix /api/v1/auth/. JWKS is served at the platform-conventional well-known path outside the /api/v1/ namespace. Specs published as OpenAPI 3.1 at services/iam-service/openapi.yaml; CI diffs against committed file on every PR.
1. Conventions
| Aspect | Rule |
|---|---|
| Media type | application/json; charset=utf-8 |
| Idempotency | Idempotency-Key: <uuid> required on all writes; 24-h TTL. |
| Tenant context | X-Tenant-Id: <ten_…> required on tenant-scoped endpoints (or carried in JWT tid). Mismatch → 403 MELMASTOON.GENERAL.TENANT_FORBIDDEN. |
| Tracing | traceparent (W3C) propagated; service injects if absent. |
| Pagination | Cursor-based: ?cursor=…&limit=… (max 100). Response: { items, nextCursor }. |
| Errors | application/problem+json envelope per ERROR_CODES.md. |
| Auth | Authorization: Bearer <accessJwt> for user routes; X-API-Key: <mlk_…> for machine routes. |
| Rate limit | Per-IP and per-email; advertised in Retry-After + X-RateLimit-* headers. |
| TLS | TLS 1.3 only; HSTS 1y; pinned in mobile clients. |
2. Standard Error Envelope
{
"type": "https://errors.melmastoon.io/iam/invalid-credentials",
"code": "MELMASTOON.IAM.INVALID_CREDENTIALS",
"title": "Invalid credentials",
"status": 401,
"detail": "The email or password you entered is incorrect.",
"traceId": "00-3ab...-abc...-01",
"requestId": "req_01HZ...",
"tenantId": null,
"retriable": false,
"userMessageKey": "auth.invalid_credentials",
"docUrl": "https://docs.melmastoon.io/errors/IAM/INVALID_CREDENTIALS"
}
3. Auth Endpoints
3.1 POST /api/v1/auth/register
Auth: none · Idempotent: yes · Rate: 20/h per IP, 5/h per email.
POST /api/v1/auth/register HTTP/1.1
Idempotency-Key: 7b9b...-e3
X-Tenant-Id: ten_01HZ8X...
Content-Type: application/json
{
"email": "front-desk@hotel-pamir.example",
"password": "Q!7sun-river-2026",
"userType": "staff",
"tenantId": "ten_01HZ8X...",
"locale": "ps-AF"
}
202 Accepted:
{
"userId": "usr_01HZ8XW...",
"status": "pending_verification",
"verificationDispatched": true
}
| Code | Status |
|---|---|
MELMASTOON.IAM.WEAK_PASSWORD | 422 |
MELMASTOON.IAM.BREACHED_CREDENTIAL | 422 |
MELMASTOON.IAM.INVALID_EMAIL | 422 |
MELMASTOON.IAM.JIT_DISALLOWED | 403 |
MELMASTOON.GENERAL.RATE_LIMITED | 429 |
MELMASTOON.GENERAL.IDEMPOTENCY_CONFLICT | 409 |
3.2 POST /api/v1/auth/login
Auth: none · Idempotent: yes (re-issues same session if replayed within TTL).
{
"email": "gm@hotel-pamir.example",
"password": "********",
"tenantSlug": "hotel-pamir",
"deviceFingerprint": {
"value": "8c3a9f...e1",
"attributes": { "osFamily": "windows", "osVersion": "11.0", "appVersion": "1.4.2", "cpuArch": "x64", "machineIdHash": "ab12..." }
}
}
200 OK (no MFA required):
{
"requiresMfa": false,
"accessToken": "eyJhbGci...",
"refreshToken": "rft_01HZ...XK4P",
"tokenType": "Bearer",
"expiresIn": 900,
"session": { "id": "ses_01HZ...", "tenantId": "ten_01HZ...", "deviceId": "dev_01HZ..." },
"user": { "id": "usr_01HZ...", "primaryEmail": "gm@hotel-pamir.example", "userType": "staff" }
}
200 OK (MFA required):
{
"requiresMfa": true,
"mfaTicket": "mft_eyJhbGci...",
"availableFactors": ["totp", "webauthn"],
"ticketExpiresAt": "2026-04-22T10:05:00Z"
}
| Code | Status |
|---|---|
MELMASTOON.IAM.INVALID_CREDENTIALS | 401 |
MELMASTOON.IAM.ACCOUNT_LOCKED | 423 |
MELMASTOON.IAM.EMAIL_NOT_VERIFIED | 403 |
MELMASTOON.IAM.TENANT_DISABLED | 403 |
MELMASTOON.GENERAL.RATE_LIMITED | 429 |
3.3 POST /api/v1/auth/refresh
Auth: Authorization: Bearer <refreshToken> · Idempotent: No (rotation is destructive); replays detected via reuse path.
200 OK:
{
"accessToken": "eyJhbGci...",
"refreshToken": "rft_01HZ...XKQP",
"tokenType": "Bearer",
"expiresIn": 900,
"rotated": true
}
| Code | Status |
|---|---|
MELMASTOON.IAM.REFRESH_INVALID | 401 |
MELMASTOON.IAM.REFRESH_EXPIRED | 401 |
MELMASTOON.IAM.REFRESH_REUSE | 401 (and family revoked) |
MELMASTOON.IAM.SESSION_REVOKED | 401 |
3.4 POST /api/v1/auth/logout
200 OK:
{ "revoked": true, "sessionId": "ses_01HZ..." }
Revokes the refresh family for the calling session. Other sessions for the user are unaffected unless ?all=true is supplied (admin scope).
3.5 POST /api/v1/auth/mfa/enroll
Auth: Bearer <accessJwt> (with acr=fresh-auth, ≤ 5 min since password verify).
{ "type": "totp", "label": "iPhone 15" }
201 Created:
{
"factorId": "mfa_01HZ...",
"type": "totp",
"otpauthUrl": "otpauth://totp/Melmastoon:gm@hotel-pamir?secret=JBSW...&issuer=Melmastoon",
"qrPng": "data:image/png;base64,iVBOR..."
}
For webauthn:
{
"factorId": "mfa_01HZ...",
"type": "webauthn",
"creationOptions": { "challenge": "...", "rp": { "id": "id.melmastoon.io", "name": "Melmastoon" }, "pubKeyCredParams": [...] }
}
Followed by POST /api/v1/auth/mfa/verify with { factorId, proof } to mark verified.
3.6 POST /api/v1/auth/mfa/challenge
{ "mfaTicket": "mft_eyJhbGci...", "factor": "totp", "code": "418562" }
For WebAuthn: { mfaTicket, factor: "webauthn", assertion: { id, rawId, response, type } }.
200 OK: same shape as /auth/login success envelope (without mfaTicket).
| Code | Status |
|---|---|
MELMASTOON.IAM.MFA_INVALID | 401 |
MELMASTOON.IAM.MFA_TICKET_EXPIRED | 401 |
MELMASTOON.IAM.MFA_TICKET_USED | 401 |
3.7 POST /api/v1/auth/sso/oidc/{provider}/start
{ "tenantSlug": "hotel-pamir", "returnUrl": "https://backoffice.melmastoon.io/" }
200 OK:
{
"authorizeUrl": "https://accounts.google.com/o/oauth2/v2/auth?...",
"state": "st_01HZ...",
"expiresAt": "2026-04-22T10:10:00Z"
}
3.8 POST /api/v1/auth/sso/oidc/{provider}/callback
{ "code": "4/0AY0e...", "state": "st_01HZ..." }
200 OK: same as /auth/login success.
3.9 POST /api/v1/auth/sso/saml/{tenantSlug}/callback
Content-Type: application/x-www-form-urlencoded, body SAMLResponse=<base64>&RelayState=<rs>.
3.10 POST /api/v1/auth/magic-link/request
{ "email": "guest@example.com", "intent": "login", "tenantSlug": "hotel-pamir", "returnUrl": "https://book.hotel-pamir.example/checkin" }
202 Accepted always (anti-enumeration):
{ "dispatched": true, "expiresInMinutes": 10 }
3.11 POST /api/v1/auth/magic-link/verify
{ "token": "ml_01HZ...XK4P" }
200 OK: same as /auth/login success.
| Code | Status |
|---|---|
MELMASTOON.IAM.MAGIC_LINK_USED | 400 |
MELMASTOON.IAM.MAGIC_LINK_EXPIRED | 400 |
MELMASTOON.IAM.MAGIC_LINK_INVALID | 400 |
3.12 POST /api/v1/auth/password/reset/request
{ "email": "gm@hotel-pamir.example", "tenantSlug": "hotel-pamir" }
202 Accepted always (anti-enumeration):
{ "dispatched": true }
3.13 POST /api/v1/auth/password/reset/complete
{ "token": "ml_01HZ...XK4P", "newPassword": "Q!7sun-river-2026" }
200 OK:
{ "passwordReset": true, "sessionsRevoked": 4 }
3.14 POST /api/v1/auth/devices/register
Auth: Bearer <accessJwt>.
{
"platform": "electron-desktop",
"fingerprint": { "value": "8c3a9f...e1", "attributes": { "osFamily": "windows", "osVersion": "11.0", "appVersion": "1.4.2", "cpuArch": "x64", "machineIdHash": "ab12..." } },
"publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": "11qYAYK..." },
"label": "GM Front-Desk PC #3"
}
201 Created:
{
"deviceId": "dev_01HZ...",
"trusted": false,
"trustConfirmRequired": true,
"trustConfirmDispatchedTo": "gm@hotel-pamir.example"
}
3.15 POST /api/v1/auth/devices/{id}/bind-offline
Auth: Bearer <accessJwt> with acr=fresh-auth (≤ 5 min). Requires Device.platform='electron-desktop' AND Device.trusted=true.
{
"csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIIB...\n-----END CERTIFICATE REQUEST-----",
"requestedTtlDays": 7
}
201 Created:
{
"binding": {
"certificatePem": "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----",
"issuingKid": "tenant-ca-2026-04-ten_01HZ8X",
"issuedAt": "2026-04-22T10:00:00Z",
"expiresAt": "2026-04-29T10:00:00Z",
"serial": "0xab12..."
}
}
| Code | Status |
|---|---|
MELMASTOON.IAM.DEVICE_NOT_TRUSTED | 403 |
MELMASTOON.IAM.DEVICE_PLATFORM_INELIGIBLE | 422 |
MELMASTOON.IAM.OFFLINE_TTL_EXCEEDED | 422 |
MELMASTOON.IAM.FRESH_AUTH_REQUIRED | 403 |
MELMASTOON.IAM.TENANT_CA_UNAVAILABLE | 503 |
3.16 GET /.well-known/jwks.json
Auth: none · CDN-cached: 5 min, stale-while-revalidate=60.
200 OK:
{
"keys": [
{
"kid": "iam-2026-04",
"kty": "OKP",
"crv": "Ed25519",
"x": "11qYAYK...",
"use": "sig",
"alg": "EdDSA",
"iss": "https://id.melmastoon.io"
},
{
"kid": "iam-2026-03",
"kty": "OKP",
"crv": "Ed25519",
"x": "Pq8abK...",
"use": "sig",
"alg": "EdDSA",
"iss": "https://id.melmastoon.io"
}
]
}
Rotation overlap is ≥ 2 days; current + previous kid always present.
3.17 POST /api/v1/auth/api-keys
Auth: Bearer <accessJwt> (user) OR X-API-Key with admin:*.
{
"label": "lock-service production",
"scopes": ["lock:dispense"],
"propertyIds": ["prop_01HZ..."],
"expiresAt": "2027-04-22T00:00:00Z"
}
201 Created (key returned once):
{
"apiKeyId": "key_01HZ...",
"key": "mlk_01HZAB...XK4P_aw_v9zR0Q",
"prefix": "mlk_01HZAB",
"scopes": ["lock:dispense"],
"propertyIds": ["prop_01HZ..."],
"expiresAt": "2027-04-22T00:00:00Z",
"issuedAt": "2026-04-22T10:00:00Z"
}
3.18 DELETE /api/v1/auth/api-keys/{id}
200 OK:
{ "revoked": true, "revokedAt": "2026-04-22T10:00:00Z" }
3.19 POST /api/v1/users/{id}/lock
Auth: admin (role=tenant_admin for tenant scope, role=platform_admin for global).
{ "reason": "admin", "until": "2026-04-29T10:00:00Z", "note": "Suspicious activity, await audit." }
200 OK:
{ "userId": "usr_01HZ...", "status": "locked", "lockedUntil": "2026-04-29T10:00:00Z" }
3.20 POST /api/v1/users/{id}/unlock
{ "note": "Confirmed legitimate; cleared by GM." }
200 OK:
{ "userId": "usr_01HZ...", "status": "active" }
3.21 POST /api/v1/auth/switch-tenant
For chain operators. Mints a new session bound to a different tid from tids[] of current JWT.
{ "tenantId": "ten_01HZ8XY..." }
200 OK: standard login envelope.
4. JWT Claim Contract
Issued in every accessToken. Conformist contract: changes require platform-wide coordination.
| Claim | Type | Notes |
|---|---|---|
iss | string | https://id.melmastoon.io |
aud | string|string[] | melmastoon-platform; SSO-via flows include resource server. |
sub | string | UserId |
tid | string | active home TenantId; null only for platform_admin |
tids | string[] | additional tenant memberships (chain operator) |
did | string | null | DeviceId, when login was device-bound |
amr | string[] | RFC 8176 (pwd, totp, webauthn, magic_link, oidc, saml) |
acr | string | fresh-auth if password verified ≤ 5 min ago; mfa-strong if WebAuthn within session |
scope | string | space-separated scopes (issued by tenant-service enrichment) |
roles | string[] | tenant-service-injected (e.g. staff:front_desk); empty for guests |
userType | string | staff | guest | platform_admin |
iat | number | issued at (Unix s) |
exp | number | expiry (Unix s); access TTL = 900 |
nbf | number | not before |
jti | string | unique token id (ULID) |
kid | header | signing key ID (e.g. iam-2026-04) |
alg | header | always EdDSA |
Example decoded payload:
{
"iss": "https://id.melmastoon.io",
"aud": "melmastoon-platform",
"sub": "usr_01HZ8XW...",
"tid": "ten_01HZ8X...",
"tids": [],
"did": "dev_01HZ...",
"amr": ["pwd", "totp"],
"acr": "mfa-strong",
"scope": "read:reservations write:reservations read:guests",
"roles": ["staff:front_desk"],
"userType": "staff",
"iat": 1745316000,
"exp": 1745316900,
"nbf": 1745315940,
"jti": "01HZJ4...XYZ"
}
5. List Endpoints (queries)
| Method | Path | Returns |
|---|---|---|
GET | /api/v1/users/me | Current user snapshot. |
GET | /api/v1/users/me/sessions | Active sessions; cursor-paginated. |
GET | /api/v1/users/me/devices | Devices with binding status. |
GET | /api/v1/users/me/api-keys | API keys (no raw values). |
GET | /api/v1/users/me/external-identities | Linked IdPs. |
GET | /api/v1/admin/users?cursor=&limit=&filter= | Admin search; tenant-scoped. |
6. Health & Readiness
| Method | Path | Notes |
|---|---|---|
GET | /health/live | Process up. |
GET | /health/ready | DB, Redis, KMS reachable; outbox lag < 30s. |
GET | /health/startup | Migrations applied; signing key cached. |
GET | /metrics | Prometheus scrape (mTLS-only). |
7. Internal Endpoints (not customer-facing)
Exposed only on internal listener (mTLS, SPIFFE peer cert):
| Method | Path | Caller |
|---|---|---|
POST | /internal/sync/identity/delta | sync-service (Device deltas) |
POST | /internal/admin/jwks/rotate | platform key-rotator job |
POST | /internal/replay/event | ops tool (DLQ replay) |
8. Error Code Catalog (iam-service)
See Standards · ERROR_CODES. iam-specific:
| Code | HTTP | Description |
|---|---|---|
MELMASTOON.IAM.INVALID_CREDENTIALS | 401 | Wrong email or password. |
MELMASTOON.IAM.MFA_REQUIRED | 200 | Login needs MFA challenge. |
MELMASTOON.IAM.MFA_INVALID | 401 | Wrong MFA proof. |
MELMASTOON.IAM.MFA_TICKET_EXPIRED | 401 | mfaTicket past TTL. |
MELMASTOON.IAM.MFA_TICKET_USED | 401 | Ticket consumed. |
MELMASTOON.IAM.ACCOUNT_LOCKED | 423 | User in locked state. |
MELMASTOON.IAM.EMAIL_NOT_VERIFIED | 403 | Verification pending. |
MELMASTOON.IAM.TENANT_DISABLED | 403 | Tenant deactivated by admin. |
MELMASTOON.IAM.WEAK_PASSWORD | 422 | Fails policy. |
MELMASTOON.IAM.BREACHED_CREDENTIAL | 422 | HIBP hit. |
MELMASTOON.IAM.INVALID_EMAIL | 422 | Email not RFC 5322. |
MELMASTOON.IAM.SSO_STATE_INVALID | 400 | OIDC state/nonce mismatch. |
MELMASTOON.IAM.SSO_ASSERTION_INVALID | 400 | SAML signature/window invalid. |
MELMASTOON.IAM.SSO_PROVIDER_DOWN | 503 | IdP circuit open. |
MELMASTOON.IAM.JIT_DISALLOWED | 403 | Tenant policy disallows JIT. |
MELMASTOON.IAM.MAGIC_LINK_USED | 400 | Nonce already redeemed. |
MELMASTOON.IAM.MAGIC_LINK_EXPIRED | 400 | Past TTL. |
MELMASTOON.IAM.MAGIC_LINK_INVALID | 400 | Token unknown / tampered. |
MELMASTOON.IAM.REFRESH_INVALID | 401 | Token unknown. |
MELMASTOON.IAM.REFRESH_EXPIRED | 401 | Past family TTL. |
MELMASTOON.IAM.REFRESH_REUSE | 401 | Reuse detected; family revoked. |
MELMASTOON.IAM.SESSION_REVOKED | 401 | Already revoked. |
MELMASTOON.IAM.DEVICE_NOT_TRUSTED | 403 | Cannot bind / refresh offline. |
MELMASTOON.IAM.DEVICE_PLATFORM_INELIGIBLE | 422 | Only electron-desktop may bind. |
MELMASTOON.IAM.OFFLINE_TTL_EXCEEDED | 422 | Requested > policy max. |
MELMASTOON.IAM.FRESH_AUTH_REQUIRED | 403 | Re-auth required for sensitive op. |
MELMASTOON.IAM.TENANT_CA_UNAVAILABLE | 503 | KMS / tenant CA unreachable. |
MELMASTOON.IAM.API_KEY_INVALID | 401 | Unknown / revoked / expired. |
MELMASTOON.IAM.API_KEY_SCOPE_INSUFFICIENT | 403 | Scope missing. |
MELMASTOON.IAM.RECOVERY_CODE_USED | 401 | Recovery code already used. |