Skip to main content

Application Logic

:::info Source Sourced from services/identity-service/APPLICATION_LOGIC.md in the documentation repo. :::

Companion: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS

1. Application Services

The application layer orchestrates domain operations, manages transactions, and coordinates with infrastructure ports. Each use case is a single command or query handler.

1.1 Layer Structure

application/
├── commands/
│ ├── RegisterUserHandler.ts
│ ├── LoginWithPasswordHandler.ts
│ ├── LoginWithSSOHandler.ts
│ ├── RefreshTokenHandler.ts
│ ├── LogoutHandler.ts
│ ├── EnrollMFAHandler.ts
│ ├── ChallengeMFAHandler.ts
│ ├── RegisterDeviceHandler.ts
│ ├── BindDeviceForOfflineHandler.ts
│ ├── RemoveDeviceHandler.ts
│ ├── IssueAPIKeyHandler.ts
│ ├── RevokeAPIKeyHandler.ts
│ ├── RequestPasswordResetHandler.ts
│ ├── CompletePasswordResetHandler.ts
│ ├── LockAccountHandler.ts
│ ├── UnlockAccountHandler.ts
│ ├── VerifyEmailHandler.ts
│ └── HandleGDPRErasureHandler.ts
├── queries/
│ ├── GetJWKSHandler.ts
│ ├── GetUserProfileHandler.ts
│ ├── ListUserDevicesHandler.ts
│ ├── ListUserSessionsHandler.ts
│ └── ListAPIKeysHandler.ts
├── policies/
│ ├── OnUserInvitedPolicy.ts
│ └── OnGDPRRequestPolicy.ts
├── ports/
│ ├── PasswordHasher.ts
│ ├── TokenSigner.ts
│ ├── OIDCClient.ts
│ ├── SAMLClient.ts
│ ├── IdentityAuthenticationProvider.ts // native credential + session backend (in-house default)
│ ├── FederatedIdentityBroker.ts // optional: Keycloak / vendor OIDC token exchange + normalize
│ ├── EventPublisher.ts
│ ├── AIRiskClassifier.ts
│ └── BreachListChecker.ts
└── dto/
├── RegisterUserDTO.ts
├── LoginDTO.ts
├── TokenPairDTO.ts
└── ...

1.2 Identity provider ports (in-house, Keycloak, vendor OIDC)

Authentication is implemented behind ports so the same use-case handlers run regardless of whether credentials live in our Postgres (in-house / native), are delegated to Keycloak, or authenticate via upstream OIDC (Google, Microsoft, Okta, Cognito, Firebase Auth, etc.).

PortResponsibility
IdentityAuthenticationProviderPassword verification, refresh/session persistence, device/API-key flows, MFA enrollment tied to locally stored factors — default implementation: in-house (current codebase extraction target).
OIDCClientAuthorization-code + PKCE, ID token validation, JWKS fetch/cache — used for generic OIDC IdPs and vendor adapters.
SAMLClientAuthnRequest / Response validation — enterprise SAML IdPs.
FederatedIdentityBrokerOptional: normalize tokens from Keycloak (or similar) via standard OIDC or token exchange, then hand off to the same session + JWT minting pipeline as LoginWithSSO.

Wiring rules:

  1. In-house is the default provider for email/password, WebAuthn, refresh tokens, and device binding; infrastructure packages it as the in-house adapter (matches existing migrations and RLS).
  2. Keycloak uses OIDCClient + FederatedIdentityBroker to talk to realm endpoints; domain events and JWT claims remain identical to generic OIDC SSO.
  3. Firebase / Okta / Cognito placeholders use OIDCClient with vendor-specific issuer + client registration; full implementations are gated by configuration until each vendor suite is complete.
  4. Enterprise SSO (customer IdP) continues to use LoginWithSSO / LoginWithSAML handlers; provider records come from tenant-service (ConfigureSSO). Multiple IdPs per tenant are supported.

Platform JWTs are always minted by identity-service (TokenSigner) after successful authentication, preserving F03 claim shapes for downstream conformists.

2. Commands

2.1 RegisterUser

Trigger: POST /api/v1/auth/register

Input:

interface RegisterUserCommand {
email: Email;
password?: string; // required unless SSO-initiated
homeTenantId?: TenantId;
registrationSource: 'self' | 'sso_jit' | 'invite' | 'bulk_import';
idempotencyKey: ULID;
}

Flow:

  1. Validate email format and uniqueness (query users table).
  2. If password provided, validate against PasswordPolicyService.
  3. Hash password via PasswordHasher port.
  4. Create User aggregate with status: pending_verification.
  5. Create Credential entity (kind: password).
  6. Persist user + credential in single transaction.
  7. Write identity.user.registered.v1 to outbox (same transaction).
  8. Return userId and confirmation that verification email will be sent.

Error Cases:

  • Email already registered: 409 resource.conflict
  • Password too weak: 422 validation.field_invalid
  • Rate limited: 429 rate.limited

Idempotency: If idempotencyKey matches an existing registration, return the original userId.

2.2 LoginWithPassword

Trigger: POST /api/v1/auth/login

Input:

interface LoginWithPasswordCommand {
email: Email;
password: string;
deviceId?: DeviceId;
tenantId?: TenantId; // if switching tenant at login
idempotencyKey: ULID;
}

Flow:

  1. Load User aggregate by email.
  2. Check user.status is active (not locked, disabled, or pending).
  3. Check credential.lockedUntil — if locked, return time remaining.
  4. Verify password via PasswordHasher.verify() (constant-time).
  5. On failure: increment failedAttempts, apply LockoutPolicyService, return 401.
  6. On success: reset failedAttempts.
  7. Evaluate AdaptiveMFAService — if MFA required, return 401 auth.mfa_required with challenge context.
  8. Resolve tenantId: use provided, else homeTenantId, else first available.
  9. Create Session aggregate.
  10. Sign access JWT (15 min) and generate refresh token (30 d).
  11. Hash refresh token, store in session.
  12. Write identity.user.logged_in.v1 to outbox.
  13. Return { accessToken, refreshToken, expiresIn, user: { id, email, tenantId, availableTenants } }.

Security:

  • Password verification is constant-time regardless of correctness.
  • Failed login for non-existent email takes same time as existent email (no user enumeration).
  • IP and user-agent logged for audit.

2.3 LoginWithSSO (OIDC)

Trigger: POST /api/v1/auth/sso/{provider}/start → redirect → GET /api/v1/auth/sso/{provider}/callback

Start Flow:

  1. Look up provider configuration for the tenant.
  2. Generate PKCE code verifier + challenge.
  3. Generate state parameter (signed, contains tenantId, returnUrl, nonce).
  4. Store state in Redis (5 min TTL).
  5. Return redirect URL to IdP authorization endpoint.

Callback Flow:

  1. Validate state parameter (signature, expiry, replay).
  2. Exchange authorization code for tokens via OIDCClient.
  3. Validate ID token (signature, nonce, audience, issuer).
  4. Extract sub, email, name from claims.
  5. Look up ExternalIdentity by (provider, subject, issuer).
  6. If found: load user, create session, return tokens.
  7. If not found: JIT provision — create User (email_verified based on IdP claim), link ExternalIdentity, create session, return tokens.
  8. Write identity.user.registered.v1 (if JIT) and identity.user.logged_in.v1 to outbox.

2.4 LoginWithSAML

Trigger: POST /api/v1/auth/sso/{provider}/start (SAML provider) → redirect → POST /api/v1/auth/sso/{provider}/callback

Flow:

  1. Load SAML IdP metadata (cached, refreshed every 24h).
  2. Generate AuthnRequest with RequestID and Destination.
  3. Store RequestID in Redis (5 min TTL).
  4. Redirect to IdP SSO URL.
  5. On callback: validate SAML Response (signature, audience, InResponseTo, time window).
  6. Extract NameID, attributes.
  7. Map to user via ACL (same JIT logic as OIDC).
  8. Create session, return tokens.

2.5 RefreshToken

Trigger: POST /api/v1/auth/refresh

Input:

interface RefreshTokenCommand {
refreshToken: string;
idempotencyKey: ULID;
}

Flow:

  1. Hash provided refresh token.
  2. Look up session by refreshTokenHash.
  3. If no match: no-op (token already rotated or invalid).
  4. If session is revoked: return 401.
  5. If session is expired: return 401.
  6. Rotation reuse detection: If hash matches a previously used hash (not current), revoke entire session family and emit identity.session.revoked.v1 with reason rotation_reuse. Return 401.
  7. Generate new refresh token, hash it, update session.
  8. Sign new access JWT.
  9. Return new token pair.

Security: Refresh token rotation with family revocation on reuse is the primary defense against refresh token theft.

2.6 Logout

Trigger: POST /api/v1/auth/logout

Flow:

  1. Extract sessionId from JWT jti claim.
  2. Load session.
  3. Set revokedAt = now().
  4. Write identity.session.revoked.v1 to outbox with reason logout.
  5. Return 204.

2.7 EnrollMFA

Trigger: POST /api/v1/users/me/mfa/enroll

Input:

interface EnrollMFACommand {
kind: MFAKind; // 'totp' | 'webauthn' | 'recovery_codes'
idempotencyKey: ULID;
}

Flow (TOTP):

  1. Generate random 160-bit TOTP secret.
  2. Create MFAFactor with verified: false.
  3. Return { secret, provisioningUri, qrCode }.
  4. User must call ChallengeMFA with a valid TOTP code to verify.
  5. On verification, set verified: true.

Flow (Recovery Codes):

  1. Generate 10 random 8-character alphanumeric codes.
  2. Hash each code, store hashes in metadata.
  3. Return raw codes (shown once).

2.8 ChallengeMFA

Trigger: POST /api/v1/users/me/mfa/challenge

Input:

interface ChallengeMFACommand {
factorId: string;
code: string; // TOTP code or recovery code
sessionContext?: string; // if completing step-up during login
idempotencyKey: ULID;
}

Flow:

  1. Load MFA factor.
  2. Validate code against factor (TOTP: time-based HMAC; recovery: hash compare + mark used).
  3. If valid and this is a login step-up: complete session creation, return tokens.
  4. If valid and this is factor verification: set verified: true.
  5. If invalid: increment attempt counter, apply lockout.

2.9 RegisterDevice

Trigger: POST /api/v1/users/me/devices

Input:

interface RegisterDeviceCommand {
fingerprint: string;
publicKey: string; // PEM-encoded
userAgent: string;
idempotencyKey: ULID;
}

Flow:

  1. Validate public key format (must be valid PEM, supported algorithm).
  2. Check device count for user (max 5).
  3. Compute fingerprint hash.
  4. Check uniqueness of (userId, fingerprintHash).
  5. Create Device aggregate.
  6. Return deviceId.

2.10 BindDeviceForOffline

Trigger: POST /api/v1/users/me/devices/{id}/bind-offline

Flow:

  1. Load device; verify ownership.
  2. Verify device is trusted (trustedAt is set).
  3. Generate X.509 certificate binding (userId, deviceId, publicKey) signed by identity-service's CA key (KMS-backed).
  4. Set certificate expiry to 90 days.
  5. Store certificate on device aggregate.
  6. Write identity.device.bound_for_offline.v1 to outbox.
  7. Return { certificate, expiresAt }.

2.11 IssueAPIKey

Trigger: POST /api/v1/api-keys

Input:

interface IssueAPIKeyCommand {
tenantId: TenantId;
name: string;
scopes: Scope[];
expiresAt?: ISODate;
idempotencyKey: ULID;
}

Flow:

  1. Validate scopes against caller's permissions (cannot escalate).
  2. Check API key count for tenant (max 20).
  3. Generate cryptographically random 256-bit key.
  4. Store { prefix: first8chars, hash: SHA256(key) }.
  5. Write identity.api_key.issued.v1 to outbox.
  6. Return { id, rawKey, prefix, scopes, expiresAt }rawKey shown once.

2.12 RevokeAPIKey

Trigger: DELETE /api/v1/api-keys/{id}

Flow:

  1. Load API key; verify tenant ownership.
  2. Set revokedAt = now().
  3. Write identity.api_key.revoked.v1 to outbox.
  4. Return 204.

2.13 RequestPasswordReset

Trigger: POST /api/v1/auth/password/reset/request

Input:

interface RequestPasswordResetCommand {
email: Email;
idempotencyKey: ULID;
}

Flow:

  1. Look up user by email. Always return 200 regardless of whether user exists (prevent enumeration).
  2. If user exists: generate reset token (ULID), store hash in Redis with 1h TTL.
  3. Write identity.password.reset_requested.v1 to outbox (notification-service sends email).
  4. Return 200 { message: "If an account exists, a reset link has been sent." }.

2.14 CompletePasswordReset

Trigger: POST /api/v1/auth/password/reset/complete

Input:

interface CompletePasswordResetCommand {
token: string;
newPassword: string;
idempotencyKey: ULID;
}

Flow:

  1. Hash token, look up in Redis.
  2. If not found or expired: 401 auth.invalid_token.
  3. Validate new password against PasswordPolicyService.
  4. Load user, update credential hash.
  5. Revoke all active sessions (emit identity.session.revoked.v1 for each with reason password_reset).
  6. Delete reset token from Redis.
  7. Return 200.

2.15 HandleGDPRErasure

Trigger: Consumed event gdpr.subject_request.received.v1

Flow:

  1. Load all data for the subject user.
  2. Anonymize:
    • Replace primaryEmail with deleted_{userId}@anonymized.ghasi.io.
    • Delete all credentials.
    • Delete all sessions.
    • Delete all devices and certificates.
    • Delete all MFA factors.
    • Delete all external identities.
    • Delete all API keys owned by user.
  3. Set user.status = 'disabled'.
  4. Persist all changes in single transaction.
  5. Write gdpr.subject_request.acknowledged.v1 to outbox.

3. Queries

QueryEndpointDescription
GetJWKSGET /.well-known/jwks.jsonReturn public signing keys in JWKS format; cached aggressively (1h, stale-while-revalidate 24h)
GetUserProfileGET /api/v1/users/meReturn authenticated user's identity data (email, status, MFA status, devices)
ListUserDevicesGET /api/v1/users/me/devicesReturn user's registered devices with trust and offline status
ListUserSessionsGET /api/v1/users/me/sessionsReturn user's active sessions (IP, UA, device, issuedAt)
ListAPIKeysGET /api/v1/api-keysReturn tenant's API keys (prefix, name, scopes, created, last used; never the hash)

4. Saga Participation

4.1 GDPR Erasure Saga

Identity-service is a participant (not orchestrator) in the GDPR Erasure Saga.

gdpr.subject_request.received.v1


┌─────────────────────────┐
│ HandleGDPRErasure │
│ - Anonymize user data │
│ - Delete credentials │
│ - Revoke all sessions │
│ - Delete devices/keys │
└────────────┬────────────┘


gdpr.subject_request.acknowledged.v1

Timeout: Must acknowledge within 72 hours. If processing fails, the event is retried via inbox pattern. After 10 retries, it lands in DLQ and triggers operator alert.

Compensation: N/A — erasure is a terminal operation. If partially completed, manual intervention resolves.

4.2 Tenant User Invitation

Identity-service participates as a reactor to tenant.org.user_invited.v1:

tenant.org.user_invited.v1


┌─────────────────────────┐
│ OnUserInvitedPolicy │
│ - Check if user exists │
│ - If not: create shadow│
│ user (pending_verif) │
│ - If exists: no-op │
└─────────────────────────┘

5. Cross-Cutting Policies

5.1 Rate Limiting

EndpointLimitWindowKey
POST /auth/login10 attempts1 minuteIP + email hash
POST /auth/register5 registrations5 minutesIP
POST /auth/password/reset/request3 requests15 minutesIP + email hash
POST /auth/refresh30 refreshes1 minuteuserId
POST /auth/sso/*/start10 starts1 minuteIP
POST /users/me/mfa/challenge5 attempts5 minutesuserId + factorId
All other writes60 requests1 minuteuserId

5.2 Idempotency

All write endpoints require Idempotency-Key header. Keys are stored as (tenantId, userId, route, key) -> { requestHash, responseSnapshot } in Redis with 24h TTL.

5.3 Audit Logging

Every command execution produces a structured audit log entry:

{
action: string; // 'user.register', 'user.login', etc.
actor: { type: string; id: string };
target: { type: string; id: string };
result: 'success' | 'failure';
reason?: string; // failure reason
ip: string;
ua: string;
tenantId?: TenantId;
timestamp: ISODate;
traceId: string;
}