iam-service — Application Logic
Catalog · DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · SECURITY_MODEL
The application layer is the thin orchestration shell between the Presentation and Domain layers. It implements use cases (commands + queries), defines ports, and coordinates policies (idempotency, rate limit, transaction). It performs zero business decisions — those belong to the domain.
1. Layer Layout
src/application/
├── commands/ # one file per command + handler
│ ├── register-user.ts
│ ├── login-with-password.ts
│ ├── login-with-sso.ts
│ ├── login-with-webauthn.ts
│ ├── login-with-magic-link.ts
│ ├── rotate-refresh-token.ts
│ ├── enroll-mfa.ts
│ ├── challenge-mfa.ts
│ ├── register-device.ts
│ ├── bind-device-for-offline.ts
│ ├── issue-api-key.ts
│ ├── revoke-api-key.ts
│ ├── request-password-reset.ts
│ ├── complete-password-reset.ts
│ ├── lock-account.ts
│ ├── unlock-account.ts
│ ├── handle-tenant-created.ts # event-driven
│ ├── handle-tenant-deleted.ts # event-driven
│ └── handle-gdpr-erasure.ts # event-driven
├── queries/
│ ├── get-jwks.ts
│ ├── get-user-snapshot.ts
│ ├── list-user-devices.ts
│ ├── list-user-sessions.ts
│ └── list-user-api-keys.ts
├── ports/ # all infrastructure contracts
│ ├── password-hasher.ts
│ ├── token-signer.ts
│ ├── token-verifier.ts
│ ├── refresh-token-generator.ts
│ ├── oidc-client.ts
│ ├── saml-client.ts
│ ├── webauthn-server.ts
│ ├── magic-link-issuer.ts
│ ├── totp-engine.ts
│ ├── breach-list-checker.ts
│ ├── email-sender.ts # routes via notification-service
│ ├── ai-classifier.ts # adaptive MFA via ai-orchestrator-service
│ ├── tenant-ca-signer.ts # signs offline device certs
│ ├── event-publisher.ts # outbox writer
│ ├── idempotency-store.ts
│ ├── rate-limiter.ts
│ ├── clock.ts
│ └── id-generator.ts
├── policies/
│ ├── idempotency.policy.ts
│ ├── rate-limit.policy.ts
│ └── tenant-context.policy.ts
└── dto/
├── access-token.dto.ts
└── login-result.dto.ts
2. Ports (selected)
export interface PasswordHasher {
hash(plaintext: string, params?: Argon2Params): Promise<PasswordHash>;
verify(plaintext: string, hash: PasswordHash): Promise<boolean>;
needsRehash(hash: PasswordHash): boolean;
}
export interface TokenSigner {
signAccess(claims: AccessClaims): Promise<{ token: string; kid: string; expiresAt: ISODate }>;
signOfflineDeviceCert(csr: CSR, opts: { tenantId: TenantId; ttlDays: number }): Promise<OfflineBinding>;
}
export interface RefreshTokenGenerator {
issue(): { token: string; hash: string }; // 256 bits, opaque, base64url
}
export interface OIDCClient {
buildAuthRequest(provider: ExternalProvider, ctx: SSOContext): Promise<{ url: string; state: string; nonce: string; pkceVerifier: string }>;
validateCallback(provider: ExternalProvider, params: OIDCCallback, ctx: SSOContext): Promise<OIDCAttributes>;
}
export interface SAMLClient {
buildAuthRequest(tenantId: TenantId, ctx: SSOContext): Promise<{ url: string; relayState: string }>;
validateAssertion(tenantId: TenantId, samlResponse: string, ctx: SSOContext): Promise<SAMLAttributes>;
}
export interface WebAuthnServer {
beginRegistration(user: User): Promise<PublicKeyCredentialCreationOptions>;
finishRegistration(user: User, attestation: AuthenticatorAttestationResponse): Promise<WebAuthnCredential>;
beginAuthentication(user: User): Promise<PublicKeyCredentialRequestOptions>;
finishAuthentication(user: User, assertion: AuthenticatorAssertionResponse): Promise<{ verified: true; credentialId: string }>;
}
export interface MagicLinkIssuer {
issue(email: Email, tenantId: TenantId | null, opts: { ttlMin: number; intent: 'login' | 'register' | 'reset' }): Promise<{ token: string; expiresAt: ISODate }>;
redeem(token: string): Promise<{ email: Email; tenantId: TenantId | null; intent: string } | { invalid: true }>;
}
export interface BreachListChecker {
isPwned(plaintext: string): Promise<boolean>; // k-anonymity HIBP
}
export interface EmailSender {
send(input: { to: Email; templateKey: string; data: Record<string, unknown>; tenantId: TenantId | null; idempotencyKey: string }): Promise<void>;
}
export interface AIClassifier {
classifyLoginRisk(ctx: LoginContext): Promise<{ score: number; reasons: string[]; provenance: AIProvenance }>;
}
export interface TenantCASigner {
signDeviceCSR(input: { tenantId: TenantId; csr: CSR; ttlDays: number }): Promise<OfflineBinding>;
}
export interface EventPublisher {
enqueue(event: DomainEvent, tx: Transaction): Promise<void>; // writes to outbox in same tx
}
export interface IdempotencyStore {
reserve(key: string, route: string, requestHash: string, ttl: Duration): Promise<'reserved' | { replay: StoredResponse }>;
commit(key: string, response: StoredResponse): Promise<void>;
}
export interface RateLimiter {
hit(bucket: string, opts: RateLimitOpts): Promise<{ allowed: boolean; remaining: number; resetAt: ISODate }>;
}
3. Use Case: RegisterUser
| Field | Value |
|---|---|
| Trigger | POST /api/v1/auth/register |
| Idempotent? | Yes (Idempotency-Key required) |
| Tx scope | Single Postgres tx (User + Credential + outbox) |
3.1 Flow
- Validate input (email format, password ≥ 12 chars).
RateLimiter.hit('register:ip:'+ip, { limit: 20, windowSec: 3600 }).IdempotencyStore.reserve(key, route, hash(input)).- Check
BreachListChecker.isPwned(password)→ if true → rejectMELMASTOON.IAM.BREACHED_CREDENTIAL. PasswordPolicyService.validate(...)→MELMASTOON.IAM.WEAK_PASSWORDon failure.- Begin tx.
- Repo: load user by email; if exists, return generic success (do NOT leak existence). Mint magic-link as recovery prompt.
User.register({ email, hash, tenantId?, userType })→ producesmelmastoon.iam.user.registered.v1.- Save aggregate, append events to outbox.
- Commit tx.
EmailSender.send({ templateKey: 'verify_email', ... }).- Return 202 (verification pending) with idempotency replay envelope.
3.2 Failure modes
| Failure | Code | HTTP |
|---|---|---|
| Weak password | MELMASTOON.IAM.WEAK_PASSWORD | 422 |
| Breached password | MELMASTOON.IAM.BREACHED_CREDENTIAL | 422 |
| Rate limit | MELMASTOON.GENERAL.RATE_LIMITED | 429 |
| Replay (different body) | MELMASTOON.GENERAL.IDEMPOTENCY_CONFLICT | 409 |
4. Use Case: LoginWithPassword
| Field | Value |
|---|---|
| Trigger | POST /api/v1/auth/login |
| Constant time? | Yes — same code path on bad email vs bad password. |
4.1 Flow
RateLimiter.hit('login:ip:'+ip, { limit: 10, windowSec: 60 }).RateLimiter.hit('login:email:'+sha(email), { limit: 30, windowSec: 60 }).- Repo: load user by
(tenantId?, email). - If absent → run dummy hash to equalize timing → return
MELMASTOON.IAM.INVALID_CREDENTIALS. - If
User.status ∈ {locked, disabled, erased}→ returnMELMASTOON.IAM.ACCOUNT_LOCKED(status=423). PasswordHasher.verify(...)→- fail:
User.recordLoginFailure(reason='invalid_password')→ save (lock if threshold) → emitmelmastoon.iam.user.login_failed.v1(and…locked.v1if applicable) → returnINVALID_CREDENTIALS. - success: continue.
- fail:
PasswordHasher.needsRehash(...)→ if true, rehash with current params and update.AIClassifier.classifyLoginRisk(ctx)(best-effort, fallback to rules).AdaptiveMFAService.evaluate({ user, device, history, aiScore }):- If
challenge ≠ 'none'and the user has the factor → returnmfa_requiredenvelope with amfaTicket(signed, 5 min TTL); HTTP 200. - Else continue to mint session.
- If
User.recordLoginSuccess(amr=['pwd', ...mfa])→ emitmelmastoon.iam.user.login_succeeded.v1.- Generate refresh token + chain via
RefreshTokenGenerator.issue(). TokenSigner.signAccess(claims)(KMS).- Persist
SessionwithrefreshChain,deviceId?,amr. - Append events to outbox; commit.
- Return
{ accessToken, refreshToken, expiresIn, sessionId, requiresMfa: false }.
4.2 Tenant resolution
- If body contains
tenantSlug→ resolve totenantIdviatenant-serviceprojection cached in Redis (5-min TTL). - If user is platform admin →
tid=null. - If user is chain operator → first tenant from
tids[]becomestid; switch via/auth/switch-tenant.
5. Use Case: LoginWithSSO (OIDC + SAML)
5.1 OIDC
| Step | Action |
|---|---|
| 1 | Client POST /auth/sso/oidc/{provider}/start with Idempotency-Key and optional returnUrl. |
| 2 | App: OIDCClient.buildAuthRequest → store { state, nonce, pkceVerifier, returnUrl } in Redis (10 min TTL). |
| 3 | Browser → IdP → callback POST /auth/sso/oidc/{provider}/callback. |
| 4 | App: OIDCClient.validateCallback(...) → claims. |
| 5 | Repo: find ExternalIdentity(provider, sub). If absent → JIT provision: create User(userType='staff', tenantId=resolved, email=claims.email) + link. |
| 6 | Adaptive MFA evaluation (skipped if IdP-asserted MFA via amr). |
| 7 | Mint session. Emit melmastoon.iam.external_identity.linked.v1 (if first link) + melmastoon.iam.user.login_succeeded.v1. |
5.2 SAML
Same shape with SAML-specific replay protection (InResponseTo ↔ session state, NotOnOrAfter/NotBefore, Issuer allowlist per tenant).
5.3 Failures
| Code | When |
|---|---|
MELMASTOON.IAM.SSO_STATE_INVALID | State/nonce mismatch. |
MELMASTOON.IAM.SSO_ASSERTION_INVALID | Signature / clock / audience fail. |
MELMASTOON.IAM.SSO_PROVIDER_DOWN | Circuit open or timeout. |
MELMASTOON.IAM.JIT_DISALLOWED | Tenant policy disallows JIT for this provider. |
6. Use Case: LoginWithWebAuthn
| Step | Action |
|---|---|
| 1 | POST /auth/webauthn/begin → WebAuthnServer.beginAuthentication(user) → returns challenge stored in Redis. |
| 2 | Browser/Electron creates assertion. |
| 3 | POST /auth/webauthn/finish → finishAuthentication(...); verifies signature, monotonic signCount. |
| 4 | Resolve tenant; mint session with amr=['webauthn']. |
7. Use Case: LoginWithMagicLink
| Step | Action |
|---|---|
| 1 | POST /auth/magic-link/request → MagicLinkIssuer.issue (10 min TTL, single use); EmailSender.send. |
| 2 | Always responds 202 Accepted (no enumeration). |
| 3 | User clicks link → POST /auth/magic-link/verify with token. |
| 4 | MagicLinkIssuer.redeem(token) → consumes nonce in Redis (atomic INCR + expire). |
| 5 | If new email → JIT provision guest. Mint session with amr=['magic_link']. |
8. Use Case: RotateRefreshToken
| Step | Action |
|---|---|
| 1 | POST /auth/refresh with Authorization: Bearer <refresh>. |
| 2 | RefreshTokenFamilyService.rotate(session, presented). |
| 3 | If reuseDetected → revoke entire family (reason='rotation_reuse'); emit melmastoon.iam.session.revoked.v1; return 401 MELMASTOON.IAM.REFRESH_REUSE. |
| 4 | Else: persist new chain head (atomic UPDATE with WHERE current_token_hash = OLD_HASH); emit melmastoon.iam.session.refreshed.v1. |
| 5 | Sign new access JWT; return both tokens. |
Concurrency: the UPDATE is conditional on the previous hash; concurrent rotators race and the loser sees a stale hash → reuse-detection path triggers.
9. Use Case: EnrollMFA
| Step | Action |
|---|---|
| 1 | POST /users/me/mfa/enroll body `{ type: 'totp' |
| 2 | TOTP → generate secret, return otpauth URL + QR. WebAuthn → WebAuthnServer.beginRegistration. |
| 3 | Client confirms → POST /users/me/mfa/verify. |
| 4 | User.enrollMFA(verifiedFactor) → emit melmastoon.iam.user.mfa_enrolled.v1. |
10. Use Case: ChallengeMFA
| Step | Action |
|---|---|
| 1 | POST /auth/mfa/challenge with mfaTicket + factor proof. |
| 2 | Verify ticket signature (HS256, secret in Secret Manager, 5 min TTL, single-use). |
| 3 | Verify proof per factor type (TOTP code, WebAuthn assertion, recovery code). |
| 4 | If success → mint session with combined amr=['pwd', factor]. |
11. Use Case: RegisterDevice
| Step | Action |
|---|---|
| 1 | POST /users/me/devices body { fingerprint, publicKeyJwk?, platform }. |
| 2 | Upsert by (userId, fingerprint.value) (handles race). |
| 3 | If DeviceTrustService.shouldAutoTrust(...) → trusted=true immediately; else require email confirm. |
| 4 | Emit melmastoon.iam.device.registered.v1. |
12. Use Case: BindDeviceForOffline (Electron only)
| Step | Action |
|---|---|
| 1 | POST /users/me/devices/{id}/bind-offline. Requires platform='electron-desktop' and trusted=true. |
| 2 | Generate Ed25519 keypair on device → CSR sent in body. |
| 3 | TenantCASigner.signDeviceCSR(...) (KMS) → returns OfflineBinding (cert PEM, 7d TTL by tenant policy). |
| 4 | Persist binding; emit melmastoon.iam.device.bound_for_offline.v1 (consumed by sync-service to gate offline bundles). |
13. Use Case: IssueAPIKey
| Step | Action |
|---|---|
| 1 | POST /api-keys body { label, scopes[], propertyIds?[], expiresAt? }. Requires userType ≠ 'guest'. |
| 2 | Validate scopes are a subset of issuer's scopes. |
| 3 | Generate raw key mlk_<26 base32>; hash with argon2id; store hash + prefix. |
| 4 | Emit melmastoon.iam.apikey.issued.v1. |
| 5 | Return raw key once in response body. |
14. Use Case: RevokeAPIKey
| Step | Action |
|---|---|
| 1 | DELETE /api-keys/{id} (self) or admin route. |
| 2 | Update revoked=true, revokedReason. |
| 3 | Emit melmastoon.iam.apikey.revoked.v1. |
| 4 | Push key prefix to Redis denylist (60-min TTL — bridges replication lag for verifiers). |
15. Use Case: RequestPasswordReset / CompletePasswordReset
- Request: identical 202 response regardless of email existence (anti-enumeration). Mints magic-link with
intent='reset'. Emitsmelmastoon.iam.password.reset_requested.v1only if user exists (consumed by notification-service to send email — handled inside the use case via outbox so saga is consistent). - Complete: redeem token → validate new password →
User.changePassword(by='reset')→ revoke all sessions for that user (reason='password_changed') → emit…password.reset_completed.v1.
16. Use Case: LockAccount / UnlockAccount
| Trigger | Lock Reason |
|---|---|
| 5 failed attempts in 15 min | lockout (auto-expires) |
| Admin action (tenant or platform) | admin (manual unlock) |
| Breach-list match on stored hash | breached_credential (unlock requires reset) |
melmastoon.tenant.deleted.v1 consumed | tenant_deleted |
Locks are domain operations; events emitted via outbox.
17. Event-Driven Handlers
17.1 melmastoon.tenant.created.v1
async function handleTenantCreated(evt: TenantCreated): Promise<void> {
await idempotency.guard(`tenant-created:${evt.eventId}`);
// Provision the tenant's first super-admin User if owner email is present.
const existing = await users.findByEmail(evt.payload.tenantId, evt.payload.ownerEmail);
if (existing) return;
await users.create({ tenantId: evt.payload.tenantId, primaryEmail: evt.payload.ownerEmail, userType: 'staff', status: 'pending_verification' });
await magicLinks.issueAndEmail(evt.payload.ownerEmail, 'register');
}
17.2 melmastoon.tenant.deleted.v1
async function handleTenantDeleted(evt: TenantDeleted): Promise<void> {
await idempotency.guard(`tenant-deleted:${evt.eventId}`);
await sessions.revokeAllForTenant(evt.payload.tenantId, 'tenant_deleted');
await apiKeys.revokeAllForTenant(evt.payload.tenantId, 'tenant_deleted');
await users.disableAllForTenant(evt.payload.tenantId);
}
17.3 melmastoon.tenant.guest.erasure_requested.v1 (GDPR saga)
async function handleGuestErasure(evt: GuestErasure): Promise<void> {
await idempotency.guard(`gdpr:${evt.payload.subjectRequestId}`);
// Anonymize user + delete credentials/sessions/devices/MFA/APIKeys/external identities.
await tx(async (t) => {
await users.anonymize(evt.payload.userId, t);
await credentials.deleteForUser(evt.payload.userId, t);
await sessions.revokeAllForUser(evt.payload.userId, 'tenant_deleted', t);
await devices.deleteForUser(evt.payload.userId, t);
await mfaFactors.deleteForUser(evt.payload.userId, t);
await apiKeys.revokeAllForUser(evt.payload.userId, 'admin_revoke', t);
await externalIdentities.deleteForUser(evt.payload.userId, t);
await events.enqueue({ subject: 'melmastoon.iam.user.erased.v1', payload: { subjectRequestId: evt.payload.subjectRequestId, userId: evt.payload.userId } }, t);
});
}
18. Cross-Cutting Policies
| Policy | Where | Detail |
|---|---|---|
| Idempotency | All write commands | Idempotency-Key header + route + sha256(body) keyed in Postgres idempotency_keys (24-h TTL); replay returns stored response. |
| Rate limit | Login / register / reset / refresh / magic-link | Redis sliding-window counter; per-IP and per-email; failures emit MELMASTOON.GENERAL.RATE_LIMITED. |
| Tenant context | Every tenant-scoped command | app.tenant_id GUC set on Postgres connection; mismatch → MELMASTOON.GENERAL.TENANT_FORBIDDEN. |
| Outbox | Every command emitting events | Event row inserted in same tx as state change; relay worker publishes to Pub/Sub. |
| Audit log | Every security-sensitive command | Append-only row in audit_events (separate table; no UPDATE/DELETE). |
| Clock | Domain operations | Injected Clock port; never Date.now() directly — enables deterministic tests. |
19. Saga Participation
| Saga | Role | Inbound | Outbound |
|---|---|---|---|
| Tenant onboarding | Participant | melmastoon.tenant.created.v1 | melmastoon.iam.user.registered.v1 (super-admin shell) |
| Tenant offboarding | Participant | melmastoon.tenant.deleted.v1 | melmastoon.iam.session.revoked.v1*N · melmastoon.iam.apikey.revoked.v1*M |
| GDPR erasure | Participant | melmastoon.tenant.guest.erasure_requested.v1 | melmastoon.iam.user.erased.v1 |
| Booking | Not involved | — | — |
| Lock provisioning | Source of API key | n/a | melmastoon.iam.apikey.issued.v1 (scoped lock:dispense) |
20. Error Code Surface (selected)
See API_CONTRACTS.md for full error catalog. Standard ones used here:
| Code | HTTP | Domain |
|---|---|---|
MELMASTOON.IAM.INVALID_CREDENTIALS | 401 | login |
MELMASTOON.IAM.MFA_REQUIRED | 200* | login (envelope requiresMfa=true) |
MELMASTOON.IAM.MFA_INVALID | 401 | challenge |
MELMASTOON.IAM.ACCOUNT_LOCKED | 423 | login |
MELMASTOON.IAM.WEAK_PASSWORD | 422 | register / reset |
MELMASTOON.IAM.BREACHED_CREDENTIAL | 422 | register / reset |
MELMASTOON.IAM.SSO_STATE_INVALID | 400 | sso |
MELMASTOON.IAM.SSO_ASSERTION_INVALID | 400 | sso |
MELMASTOON.IAM.MAGIC_LINK_USED | 400 | magic-link |
MELMASTOON.IAM.MAGIC_LINK_EXPIRED | 400 | magic-link |
MELMASTOON.IAM.REFRESH_REUSE | 401 | refresh |
MELMASTOON.IAM.DEVICE_NOT_TRUSTED | 403 | offline-bind |
MELMASTOON.IAM.OFFLINE_TTL_EXCEEDED | 422 | offline-bind |