Skip to main content

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

FieldValue
TriggerPOST /api/v1/auth/register
Idempotent?Yes (Idempotency-Key required)
Tx scopeSingle Postgres tx (User + Credential + outbox)

3.1 Flow

  1. Validate input (email format, password ≥ 12 chars).
  2. RateLimiter.hit('register:ip:'+ip, { limit: 20, windowSec: 3600 }).
  3. IdempotencyStore.reserve(key, route, hash(input)).
  4. Check BreachListChecker.isPwned(password) → if true → reject MELMASTOON.IAM.BREACHED_CREDENTIAL.
  5. PasswordPolicyService.validate(...)MELMASTOON.IAM.WEAK_PASSWORD on failure.
  6. Begin tx.
  7. Repo: load user by email; if exists, return generic success (do NOT leak existence). Mint magic-link as recovery prompt.
  8. User.register({ email, hash, tenantId?, userType }) → produces melmastoon.iam.user.registered.v1.
  9. Save aggregate, append events to outbox.
  10. Commit tx.
  11. EmailSender.send({ templateKey: 'verify_email', ... }).
  12. Return 202 (verification pending) with idempotency replay envelope.

3.2 Failure modes

FailureCodeHTTP
Weak passwordMELMASTOON.IAM.WEAK_PASSWORD422
Breached passwordMELMASTOON.IAM.BREACHED_CREDENTIAL422
Rate limitMELMASTOON.GENERAL.RATE_LIMITED429
Replay (different body)MELMASTOON.GENERAL.IDEMPOTENCY_CONFLICT409

4. Use Case: LoginWithPassword

FieldValue
TriggerPOST /api/v1/auth/login
Constant time?Yes — same code path on bad email vs bad password.

4.1 Flow

  1. RateLimiter.hit('login:ip:'+ip, { limit: 10, windowSec: 60 }).
  2. RateLimiter.hit('login:email:'+sha(email), { limit: 30, windowSec: 60 }).
  3. Repo: load user by (tenantId?, email).
  4. If absent → run dummy hash to equalize timing → return MELMASTOON.IAM.INVALID_CREDENTIALS.
  5. If User.status ∈ {locked, disabled, erased} → return MELMASTOON.IAM.ACCOUNT_LOCKED (status=423).
  6. PasswordHasher.verify(...)
    • fail: User.recordLoginFailure(reason='invalid_password') → save (lock if threshold) → emit melmastoon.iam.user.login_failed.v1 (and …locked.v1 if applicable) → return INVALID_CREDENTIALS.
    • success: continue.
  7. PasswordHasher.needsRehash(...) → if true, rehash with current params and update.
  8. AIClassifier.classifyLoginRisk(ctx) (best-effort, fallback to rules).
  9. AdaptiveMFAService.evaluate({ user, device, history, aiScore }):
    • If challenge ≠ 'none' and the user has the factor → return mfa_required envelope with a mfaTicket (signed, 5 min TTL); HTTP 200.
    • Else continue to mint session.
  10. User.recordLoginSuccess(amr=['pwd', ...mfa]) → emit melmastoon.iam.user.login_succeeded.v1.
  11. Generate refresh token + chain via RefreshTokenGenerator.issue().
  12. TokenSigner.signAccess(claims) (KMS).
  13. Persist Session with refreshChain, deviceId?, amr.
  14. Append events to outbox; commit.
  15. Return { accessToken, refreshToken, expiresIn, sessionId, requiresMfa: false }.

4.2 Tenant resolution

  • If body contains tenantSlug → resolve to tenantId via tenant-service projection cached in Redis (5-min TTL).
  • If user is platform admin → tid=null.
  • If user is chain operator → first tenant from tids[] becomes tid; switch via /auth/switch-tenant.

5. Use Case: LoginWithSSO (OIDC + SAML)

5.1 OIDC

StepAction
1Client POST /auth/sso/oidc/{provider}/start with Idempotency-Key and optional returnUrl.
2App: OIDCClient.buildAuthRequest → store { state, nonce, pkceVerifier, returnUrl } in Redis (10 min TTL).
3Browser → IdP → callback POST /auth/sso/oidc/{provider}/callback.
4App: OIDCClient.validateCallback(...) → claims.
5Repo: find ExternalIdentity(provider, sub). If absent → JIT provision: create User(userType='staff', tenantId=resolved, email=claims.email) + link.
6Adaptive MFA evaluation (skipped if IdP-asserted MFA via amr).
7Mint 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

CodeWhen
MELMASTOON.IAM.SSO_STATE_INVALIDState/nonce mismatch.
MELMASTOON.IAM.SSO_ASSERTION_INVALIDSignature / clock / audience fail.
MELMASTOON.IAM.SSO_PROVIDER_DOWNCircuit open or timeout.
MELMASTOON.IAM.JIT_DISALLOWEDTenant policy disallows JIT for this provider.

6. Use Case: LoginWithWebAuthn

StepAction
1POST /auth/webauthn/beginWebAuthnServer.beginAuthentication(user) → returns challenge stored in Redis.
2Browser/Electron creates assertion.
3POST /auth/webauthn/finishfinishAuthentication(...); verifies signature, monotonic signCount.
4Resolve tenant; mint session with amr=['webauthn'].
StepAction
1POST /auth/magic-link/requestMagicLinkIssuer.issue (10 min TTL, single use); EmailSender.send.
2Always responds 202 Accepted (no enumeration).
3User clicks link → POST /auth/magic-link/verify with token.
4MagicLinkIssuer.redeem(token) → consumes nonce in Redis (atomic INCR + expire).
5If new email → JIT provision guest. Mint session with amr=['magic_link'].

8. Use Case: RotateRefreshToken

StepAction
1POST /auth/refresh with Authorization: Bearer <refresh>.
2RefreshTokenFamilyService.rotate(session, presented).
3If reuseDetected → revoke entire family (reason='rotation_reuse'); emit melmastoon.iam.session.revoked.v1; return 401 MELMASTOON.IAM.REFRESH_REUSE.
4Else: persist new chain head (atomic UPDATE with WHERE current_token_hash = OLD_HASH); emit melmastoon.iam.session.refreshed.v1.
5Sign 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

StepAction
1POST /users/me/mfa/enroll body `{ type: 'totp'
2TOTP → generate secret, return otpauth URL + QR. WebAuthn → WebAuthnServer.beginRegistration.
3Client confirms → POST /users/me/mfa/verify.
4User.enrollMFA(verifiedFactor) → emit melmastoon.iam.user.mfa_enrolled.v1.

10. Use Case: ChallengeMFA

StepAction
1POST /auth/mfa/challenge with mfaTicket + factor proof.
2Verify ticket signature (HS256, secret in Secret Manager, 5 min TTL, single-use).
3Verify proof per factor type (TOTP code, WebAuthn assertion, recovery code).
4If success → mint session with combined amr=['pwd', factor].

11. Use Case: RegisterDevice

StepAction
1POST /users/me/devices body { fingerprint, publicKeyJwk?, platform }.
2Upsert by (userId, fingerprint.value) (handles race).
3If DeviceTrustService.shouldAutoTrust(...)trusted=true immediately; else require email confirm.
4Emit melmastoon.iam.device.registered.v1.

12. Use Case: BindDeviceForOffline (Electron only)

StepAction
1POST /users/me/devices/{id}/bind-offline. Requires platform='electron-desktop' and trusted=true.
2Generate Ed25519 keypair on device → CSR sent in body.
3TenantCASigner.signDeviceCSR(...) (KMS) → returns OfflineBinding (cert PEM, 7d TTL by tenant policy).
4Persist binding; emit melmastoon.iam.device.bound_for_offline.v1 (consumed by sync-service to gate offline bundles).

13. Use Case: IssueAPIKey

StepAction
1POST /api-keys body { label, scopes[], propertyIds?[], expiresAt? }. Requires userType ≠ 'guest'.
2Validate scopes are a subset of issuer's scopes.
3Generate raw key mlk_<26 base32>; hash with argon2id; store hash + prefix.
4Emit melmastoon.iam.apikey.issued.v1.
5Return raw key once in response body.

14. Use Case: RevokeAPIKey

StepAction
1DELETE /api-keys/{id} (self) or admin route.
2Update revoked=true, revokedReason.
3Emit melmastoon.iam.apikey.revoked.v1.
4Push 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'. Emits melmastoon.iam.password.reset_requested.v1 only 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

TriggerLock Reason
5 failed attempts in 15 minlockout (auto-expires)
Admin action (tenant or platform)admin (manual unlock)
Breach-list match on stored hashbreached_credential (unlock requires reset)
melmastoon.tenant.deleted.v1 consumedtenant_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

PolicyWhereDetail
IdempotencyAll write commandsIdempotency-Key header + route + sha256(body) keyed in Postgres idempotency_keys (24-h TTL); replay returns stored response.
Rate limitLogin / register / reset / refresh / magic-linkRedis sliding-window counter; per-IP and per-email; failures emit MELMASTOON.GENERAL.RATE_LIMITED.
Tenant contextEvery tenant-scoped commandapp.tenant_id GUC set on Postgres connection; mismatch → MELMASTOON.GENERAL.TENANT_FORBIDDEN.
OutboxEvery command emitting eventsEvent row inserted in same tx as state change; relay worker publishes to Pub/Sub.
Audit logEvery security-sensitive commandAppend-only row in audit_events (separate table; no UPDATE/DELETE).
ClockDomain operationsInjected Clock port; never Date.now() directly — enables deterministic tests.

19. Saga Participation

SagaRoleInboundOutbound
Tenant onboardingParticipantmelmastoon.tenant.created.v1melmastoon.iam.user.registered.v1 (super-admin shell)
Tenant offboardingParticipantmelmastoon.tenant.deleted.v1melmastoon.iam.session.revoked.v1*N · melmastoon.iam.apikey.revoked.v1*M
GDPR erasureParticipantmelmastoon.tenant.guest.erasure_requested.v1melmastoon.iam.user.erased.v1
BookingNot involved
Lock provisioningSource of API keyn/amelmastoon.iam.apikey.issued.v1 (scoped lock:dispense)

20. Error Code Surface (selected)

See API_CONTRACTS.md for full error catalog. Standard ones used here:

CodeHTTPDomain
MELMASTOON.IAM.INVALID_CREDENTIALS401login
MELMASTOON.IAM.MFA_REQUIRED200*login (envelope requiresMfa=true)
MELMASTOON.IAM.MFA_INVALID401challenge
MELMASTOON.IAM.ACCOUNT_LOCKED423login
MELMASTOON.IAM.WEAK_PASSWORD422register / reset
MELMASTOON.IAM.BREACHED_CREDENTIAL422register / reset
MELMASTOON.IAM.SSO_STATE_INVALID400sso
MELMASTOON.IAM.SSO_ASSERTION_INVALID400sso
MELMASTOON.IAM.MAGIC_LINK_USED400magic-link
MELMASTOON.IAM.MAGIC_LINK_EXPIRED400magic-link
MELMASTOON.IAM.REFRESH_REUSE401refresh
MELMASTOON.IAM.DEVICE_NOT_TRUSTED403offline-bind
MELMASTOON.IAM.OFFLINE_TTL_EXCEEDED422offline-bind