ADR-0042: Patient Identity Strategy — Dedicated Keycloak Clients, Single Realm
Status: Accepted
Date: 2026-03-31
Decision Makers: Platform Architect, Security Lead, Mobile Lead
Affected Components:
infra/keycloak/ghasi-realm.jsonapps/patient-portal(Next.js patient web portal)apps/ehr-patient-mobile(React Native, greenfield)apps/services/iam(provisioning)apps/services/patient-portal-api(PortalAccount lifecycle)
1. Context
The Ghasi EHR platform uses a single Keycloak realm (ghasi) as its IDP. Staff
identities — clinicians, nurses, receptionists, tenant-admins, auditors — are
provisioned by administrators.
Before this ADR the patient-portal Next.js app used the staff SPA client
(ghasi-spa) and the patient mobile app had no auth configuration at all:
| Problem | Impact |
|---|---|
patient-portal used ghasi-spa (staff client) | No independent session lifetimes, redirect URIs, or post-logout URIs for patients |
| Social login (Google, Apple) for patients would surface on staff login | Cannot use Keycloak identity-provider restrictions without separate clients |
| Mobile app had no public PKCE client | Native app with stored client_secret is a security anti-pattern |
registrationAllowed: false realm-wide, no provisioning endpoint | No automated way to create patient Keycloak identities from the platform |
2. Decision Drivers
- Security isolation between clinical staff and patient sessions
- HIPAA / healthcare data regulation — minimise blast radius if patient credentials are compromised
- Patient UX — separate session lifetimes, longer idle tolerance, optional social login
- Mobile PKCE and offline-access requirements (no
client_secretin native bundle) - Single realm to keep JWKS validation unchanged across all backend services
- Staff + clinical workflows must not regress
3. Decision
Same realm ghasi, two new dedicated Keycloak clients, plus a patient provisioning
endpoint in the IAM service.
| Client | Use case | Type |
|---|---|---|
ghasi-spa | Clinical staff (ehr-web, ehr-shell) | Confidential, existing — unchanged |
ghasi-patient-web | Patient web portal (apps/patient-portal) | Confidential, PKCE enforced |
ghasi-patient-mobile | Patient mobile app (apps/ehr-patient-mobile) | Public, PKCE mandatory |
4. Rejected Alternatives
Option A — Same realm, same ghasi-spa client (status quo)
Cannot tune session lifetimes, redirect URIs, or social IdPs independently.
Adding Google/Apple for patients would expose them on the clinical staff login page.
Mobile app would require storing a client_secret in the native bundle — a security
violation. Rejected.
Option C — Separate realm (ghasi-patients)
Backend services would need to validate JWTs from two JWKS endpoints / issuers.
Every @Roles() guard, the Kong JWT plugin configuration, and the tenant-claims
attribute model would require duplication.
Cross-tenant users (e.g. a nurse registered as their own patient) would require
unsolved identity federation. Rejected.
Option D — External IdP (Firebase Auth / Auth0 for patients)
Introduces a third-party SaaS dependency for PHI-adjacent identity — HIPAA BAA required. Adds latency and a new failure domain. Rejected.
5. Implementation
5.1 Keycloak — ghasi-patient-web Client
clientId: ghasi-patient-web
publicClient: false (confidential — Next.js server holds the secret)
standardFlowEnabled: true (Auth Code + PKCE)
directAccessGrantsEnabled: false
pkce.code.challenge.method: S256
redirectUris: http://localhost:3001/*, https://patients.ghasi.health/*
post.logout.redirect.uris: http://localhost:3001/*##https://patients.ghasi.health/*
access.token.lifespan: 900s (15 min — slightly longer than staff 5 min for patient UX)
client.session.idle.timeout: 28800s (8h)
client.session.max.lifespan: 86400s (24h)
defaultClientScopes: openid, profile, email, roles, web-origins, tenant-claims
optionalClientScopes: offline_access
5.2 Keycloak — ghasi-patient-mobile Client
clientId: ghasi-patient-mobile
publicClient: true (no client_secret — PKCE is the only protection)
standardFlowEnabled: true (Auth Code + PKCE)
pkce.code.challenge.method: S256
redirectUris: com.ghasi.ehr://callback, com.ghasi.ehr://auth,
exp://localhost:8081, http://localhost:8081/*
defaultClientScopes: openid, profile, email, roles, web-origins, tenant-claims, offline_access
access.token.lifespan: 900s
refresh.token.max.reuse: 0 (token rotation on every refresh)
Both clients are added to infra/keycloak/ghasi-realm.json.
5.3 patient-portal — Client Switch
KEYCLOAK_CLIENT_ID changed from ghasi-spa → ghasi-patient-web in:
apps/patient-portal/.env.exampleapps/patient-portal/src/lib/auth/auth-options.ts— both fallback strings.github/workflows/ci-patient-portal.yml—NEXT_PUBLIC_KEYCLOAK_CLIENT_ID
The patient portal runs on port 3001 (distinct from ehr-web on 3000) per the
redirect URIs above. Update NEXTAUTH_URL in local .env accordingly.
5.4 Patient Provisioning Flow
Account creation is always staff-gated (registrationAllowed: false is unchanged).
Registrar → registration POST /v1/patients
└── patient-portal-api POST /v1/portal/accounts
└── IAM service POST /internal/iam/patients
1. Get Keycloak admin token (admin-cli / ghasi-api)
2. Create KC user (email, firstName, lastName, tenantId attribute)
requiredActions: [VERIFY_EMAIL, UPDATE_PASSWORD]
3. Assign realm role "patient"
4. PUT send-verify-email?client_id=ghasi-patient-web
&redirect_uri=<PATIENT_PORTAL_URL>/auth/login
5. Create IAM DB record (userType=PATIENT, status=PENDING_VERIFICATION)
6. Return { keycloakId, iamUserId }
└── patient-portal-api stores keycloakId
as PortalAccount.idpSubject
New IAM service endpoint: POST /internal/iam/patients
Request body:
{
"tenantId": "tenant-001",
"email": "patient@example.com",
"givenName": "Ahmad",
"familyName": "Karimi",
"locale": "en"
}
Response:
{
"keycloakId": "<keycloak-user-uuid>",
"iamUserId": "<iam-db-uuid>"
}
New environment variables in apps/services/iam/.env.example:
| Variable | Default | Purpose |
|---|---|---|
KEYCLOAK_INTERNAL_URL | http://keycloak:8080 | Keycloak internal hostname |
KEYCLOAK_REALM | ghasi | Realm name |
KEYCLOAK_ADMIN_CLIENT_ID | admin-cli | Admin client for token exchange |
KEYCLOAK_ADMIN_CLIENT_SECRET | — | Admin client secret |
KEYCLOAK_PATIENT_ACTIVATION_CLIENT_ID | ghasi-patient-web | Client for activation email |
PATIENT_PORTAL_URL | http://localhost:3001 | Activation email redirect base URL |
5.5 Mobile Auth Stack (when ehr-patient-mobile is implemented)
Library: expo-auth-session (Expo managed) or react-native-app-auth (bare)
Flow: Authorization Code + PKCE (S256)
Client: ghasi-patient-mobile (public)
Redirect URI: com.ghasi.ehr://callback
Scopes: openid profile email offline_access tenant-claims roles
Token storage: expo-secure-store → iOS Keychain / Android Keystore
(never AsyncStorage — unencrypted)
Refresh: silent refresh on app foreground using stored refresh_token
Biometrics: Gate stored refresh_token retrieval behind Face ID / fingerprint
Logout: end_session_endpoint + SecureStore.deleteItemAsync
5.6 Future: Social Login (Phase 2)
- Add Google / Apple as Identity Providers in the
ghasirealm. - Under each patient client's settings → Identity Provider Restrictions, enable only the social IdPs — staff clients see no social login buttons.
- Configure First Broker Login flow to auto-link if the social email matches an existing verified Keycloak account.
PortalAccount.idpSubjectcontinues to store the Keycloaksub(brokered identity is transparent to backend services).
6. Session & Token Lifespan Comparison
| Setting | Staff ghasi-spa | Patient Web ghasi-patient-web | Patient Mobile ghasi-patient-mobile |
|---|---|---|---|
| Access token | 300s | 900s | 900s |
| SSO session idle | 1800s (30 min) | 28800s (8 h) | managed by refresh token |
| SSO session max | 36000s (10 h) | 86400s (24 h) | N/A (offline token) |
| Refresh token rotation | yes | yes | yes (max.reuse = 0) |
| Offline access | optional | optional | default scope |
| MFA | recommended / enforce | optional (user preference) | optional (app biometrics) |
7. Backend Services — Zero Changes Required
All patient tokens are issued by the same ghasi realm. Therefore:
- JWKS URI unchanged:
/realms/ghasi/protocol/openid-connect/certs @ghasi/auth-guardrole expansion forpatientrole: works today- Kong JWT plugin configuration: unchanged (same issuer)
tenantId/userTypeclaims intenant-claimsscope: set identically for patient users
8. Security Notes
- Client secret rotation:
ghasi-patient-websecret is rotated independently fromghasi-spa; stored in Kubernetes secrets / Vault with separate access policies. - Blast radius: Patient credentials compromise is isolated — the
patientrole has no access to clinical API endpoints protected by@Roles()guards. - No
directAccessGrantsEnabledon patient clients — prevents password-spray attacks via the token endpoint. - PKCE on
ghasi-patient-web(even though confidential) adds defence-in-depth against auth-code interception in the browser. - Mobile token leakage mitigated by PKCE (code interception) +
SecureStore(token extraction from device storage). - Activation email is sent via Keycloak's built-in email action with a signed one-time-use token — no custom token storage required in the application layer.
9. Open Questions / Deferred Decisions
| # | Question | Recommended follow-up |
|---|---|---|
| 9.1 | Per-client password policy — staff policy (length 12, special chars) is burdensome for patients | Keycloak 22+ Authentication Policy Override at the flow level → separate ADR |
| 9.2 | No-email patients — patients with only a phone number cannot receive activation email | Evaluate Keycloak SMS Authenticator SPI for OTP delivery → separate ADR |
| 9.3 | Cross-tenant patient identity — patient moves between clinics | PortalAccount already supports multiple records per idpSubject; explicitly test and document |
| 9.4 | Patient self-registration — invite-token-based account activation without direct KC email | Invite token flow in patient-portal-api (see .cursor/plans/patient-registration-enhancement.md) |
10. Consequences
Positive:
- Patient and staff auth surfaces are independently configurable from day one.
- Social login can be rolled out to patients without touching the clinical staff flow.
- Mobile app has a correct public PKCE client with native redirect URIs.
- Session lifetimes match real-world patient usage patterns.
- All identities consolidated in one realm — single point of audit and admin.
Negative / Risks:
- Three browser/native clients to monitor and rotate secrets for (previously one).
- Realm-level password policy still applies to patients until a flow override is configured (deferred, §9.1).
- SMS activation for no-email patients is a gap (deferred, §9.2).