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.).
| Port | Responsibility |
|---|---|
IdentityAuthenticationProvider | Password verification, refresh/session persistence, device/API-key flows, MFA enrollment tied to locally stored factors — default implementation: in-house (current codebase extraction target). |
OIDCClient | Authorization-code + PKCE, ID token validation, JWKS fetch/cache — used for generic OIDC IdPs and vendor adapters. |
SAMLClient | AuthnRequest / Response validation — enterprise SAML IdPs. |
FederatedIdentityBroker | Optional: 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:
- In-house is the default provider for email/password, WebAuthn, refresh tokens, and device binding; infrastructure packages it as the
in-houseadapter (matches existing migrations and RLS). - Keycloak uses
OIDCClient+FederatedIdentityBrokerto talk to realm endpoints; domain events and JWT claims remain identical to generic OIDC SSO. - Firebase / Okta / Cognito placeholders use
OIDCClientwith vendor-specific issuer + client registration; full implementations are gated by configuration until each vendor suite is complete. - Enterprise SSO (customer IdP) continues to use
LoginWithSSO/LoginWithSAMLhandlers; 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:
- Validate email format and uniqueness (query
userstable). - If password provided, validate against
PasswordPolicyService. - Hash password via
PasswordHasherport. - Create
Useraggregate withstatus: pending_verification. - Create
Credentialentity (kind: password). - Persist user + credential in single transaction.
- Write
identity.user.registered.v1to outbox (same transaction). - Return
userIdand 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:
- Load
Useraggregate by email. - Check
user.statusisactive(not locked, disabled, or pending). - Check
credential.lockedUntil— if locked, return time remaining. - Verify password via
PasswordHasher.verify()(constant-time). - On failure: increment
failedAttempts, applyLockoutPolicyService, return401. - On success: reset
failedAttempts. - Evaluate
AdaptiveMFAService— if MFA required, return401 auth.mfa_requiredwith challenge context. - Resolve
tenantId: use provided, elsehomeTenantId, else first available. - Create
Sessionaggregate. - Sign access JWT (15 min) and generate refresh token (30 d).
- Hash refresh token, store in session.
- Write
identity.user.logged_in.v1to outbox. - 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:
- Look up provider configuration for the tenant.
- Generate PKCE code verifier + challenge.
- Generate state parameter (signed, contains
tenantId,returnUrl,nonce). - Store state in Redis (5 min TTL).
- Return redirect URL to IdP authorization endpoint.
Callback Flow:
- Validate
stateparameter (signature, expiry, replay). - Exchange authorization code for tokens via
OIDCClient. - Validate ID token (signature, nonce, audience, issuer).
- Extract
sub,email,namefrom claims. - Look up
ExternalIdentityby(provider, subject, issuer). - If found: load user, create session, return tokens.
- If not found: JIT provision — create
User(email_verified based on IdP claim), linkExternalIdentity, create session, return tokens. - Write
identity.user.registered.v1(if JIT) andidentity.user.logged_in.v1to outbox.
2.4 LoginWithSAML
Trigger: POST /api/v1/auth/sso/{provider}/start (SAML provider) → redirect → POST /api/v1/auth/sso/{provider}/callback
Flow:
- Load SAML IdP metadata (cached, refreshed every 24h).
- Generate AuthnRequest with
RequestIDandDestination. - Store
RequestIDin Redis (5 min TTL). - Redirect to IdP SSO URL.
- On callback: validate SAML Response (signature, audience,
InResponseTo, time window). - Extract
NameID, attributes. - Map to user via ACL (same JIT logic as OIDC).
- Create session, return tokens.
2.5 RefreshToken
Trigger: POST /api/v1/auth/refresh
Input:
interface RefreshTokenCommand {
refreshToken: string;
idempotencyKey: ULID;
}
Flow:
- Hash provided refresh token.
- Look up session by
refreshTokenHash. - If no match: no-op (token already rotated or invalid).
- If session is revoked: return
401. - If session is expired: return
401. - Rotation reuse detection: If hash matches a previously used hash (not current), revoke entire session family and emit
identity.session.revoked.v1with reasonrotation_reuse. Return401. - Generate new refresh token, hash it, update session.
- Sign new access JWT.
- 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:
- Extract
sessionIdfrom JWTjticlaim. - Load session.
- Set
revokedAt = now(). - Write
identity.session.revoked.v1to outbox with reasonlogout. - 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):
- Generate random 160-bit TOTP secret.
- Create
MFAFactorwithverified: false. - Return
{ secret, provisioningUri, qrCode }. - User must call
ChallengeMFAwith a valid TOTP code to verify. - On verification, set
verified: true.
Flow (Recovery Codes):
- Generate 10 random 8-character alphanumeric codes.
- Hash each code, store hashes in
metadata. - 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:
- Load MFA factor.
- Validate code against factor (TOTP: time-based HMAC; recovery: hash compare + mark used).
- If valid and this is a login step-up: complete session creation, return tokens.
- If valid and this is factor verification: set
verified: true. - 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:
- Validate public key format (must be valid PEM, supported algorithm).
- Check device count for user (max 5).
- Compute fingerprint hash.
- Check uniqueness of
(userId, fingerprintHash). - Create
Deviceaggregate. - Return
deviceId.
2.10 BindDeviceForOffline
Trigger: POST /api/v1/users/me/devices/{id}/bind-offline
Flow:
- Load device; verify ownership.
- Verify device is trusted (
trustedAtis set). - Generate X.509 certificate binding
(userId, deviceId, publicKey)signed by identity-service's CA key (KMS-backed). - Set certificate expiry to 90 days.
- Store certificate on device aggregate.
- Write
identity.device.bound_for_offline.v1to outbox. - 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:
- Validate scopes against caller's permissions (cannot escalate).
- Check API key count for tenant (max 20).
- Generate cryptographically random 256-bit key.
- Store
{ prefix: first8chars, hash: SHA256(key) }. - Write
identity.api_key.issued.v1to outbox. - Return
{ id, rawKey, prefix, scopes, expiresAt }—rawKeyshown once.
2.12 RevokeAPIKey
Trigger: DELETE /api/v1/api-keys/{id}
Flow:
- Load API key; verify tenant ownership.
- Set
revokedAt = now(). - Write
identity.api_key.revoked.v1to outbox. - Return
204.
2.13 RequestPasswordReset
Trigger: POST /api/v1/auth/password/reset/request
Input:
interface RequestPasswordResetCommand {
email: Email;
idempotencyKey: ULID;
}
Flow:
- Look up user by email. Always return 200 regardless of whether user exists (prevent enumeration).
- If user exists: generate reset token (ULID), store hash in Redis with 1h TTL.
- Write
identity.password.reset_requested.v1to outbox (notification-service sends email). - 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:
- Hash token, look up in Redis.
- If not found or expired:
401 auth.invalid_token. - Validate new password against
PasswordPolicyService. - Load user, update credential hash.
- Revoke all active sessions (emit
identity.session.revoked.v1for each with reasonpassword_reset). - Delete reset token from Redis.
- Return
200.
2.15 HandleGDPRErasure
Trigger: Consumed event gdpr.subject_request.received.v1
Flow:
- Load all data for the subject user.
- Anonymize:
- Replace
primaryEmailwithdeleted_{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.
- Replace
- Set
user.status = 'disabled'. - Persist all changes in single transaction.
- Write
gdpr.subject_request.acknowledged.v1to outbox.
3. Queries
| Query | Endpoint | Description |
|---|---|---|
GetJWKS | GET /.well-known/jwks.json | Return public signing keys in JWKS format; cached aggressively (1h, stale-while-revalidate 24h) |
GetUserProfile | GET /api/v1/users/me | Return authenticated user's identity data (email, status, MFA status, devices) |
ListUserDevices | GET /api/v1/users/me/devices | Return user's registered devices with trust and offline status |
ListUserSessions | GET /api/v1/users/me/sessions | Return user's active sessions (IP, UA, device, issuedAt) |
ListAPIKeys | GET /api/v1/api-keys | Return 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
| Endpoint | Limit | Window | Key |
|---|---|---|---|
POST /auth/login | 10 attempts | 1 minute | IP + email hash |
POST /auth/register | 5 registrations | 5 minutes | IP |
POST /auth/password/reset/request | 3 requests | 15 minutes | IP + email hash |
POST /auth/refresh | 30 refreshes | 1 minute | userId |
POST /auth/sso/*/start | 10 starts | 1 minute | IP |
POST /users/me/mfa/challenge | 5 attempts | 5 minutes | userId + factorId |
| All other writes | 60 requests | 1 minute | userId |
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;
}