Skip to main content

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.json
  • apps/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:

ProblemImpact
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 loginCannot use Keycloak identity-provider restrictions without separate clients
Mobile app had no public PKCE clientNative app with stored client_secret is a security anti-pattern
registrationAllowed: false realm-wide, no provisioning endpointNo 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_secret in 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.

ClientUse caseType
ghasi-spaClinical staff (ehr-web, ehr-shell)Confidential, existing — unchanged
ghasi-patient-webPatient web portal (apps/patient-portal)Confidential, PKCE enforced
ghasi-patient-mobilePatient 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-spaghasi-patient-web in:

  • apps/patient-portal/.env.example
  • apps/patient-portal/src/lib/auth/auth-options.ts — both fallback strings
  • .github/workflows/ci-patient-portal.ymlNEXT_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:

VariableDefaultPurpose
KEYCLOAK_INTERNAL_URLhttp://keycloak:8080Keycloak internal hostname
KEYCLOAK_REALMghasiRealm name
KEYCLOAK_ADMIN_CLIENT_IDadmin-cliAdmin client for token exchange
KEYCLOAK_ADMIN_CLIENT_SECRETAdmin client secret
KEYCLOAK_PATIENT_ACTIVATION_CLIENT_IDghasi-patient-webClient for activation email
PATIENT_PORTAL_URLhttp://localhost:3001Activation 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)

  1. Add Google / Apple as Identity Providers in the ghasi realm.
  2. Under each patient client's settings → Identity Provider Restrictions, enable only the social IdPs — staff clients see no social login buttons.
  3. Configure First Broker Login flow to auto-link if the social email matches an existing verified Keycloak account.
  4. PortalAccount.idpSubject continues to store the Keycloak sub (brokered identity is transparent to backend services).

6. Session & Token Lifespan Comparison

SettingStaff ghasi-spaPatient Web ghasi-patient-webPatient Mobile ghasi-patient-mobile
Access token300s900s900s
SSO session idle1800s (30 min)28800s (8 h)managed by refresh token
SSO session max36000s (10 h)86400s (24 h)N/A (offline token)
Refresh token rotationyesyesyes (max.reuse = 0)
Offline accessoptionaloptionaldefault scope
MFArecommended / enforceoptional (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-guard role expansion for patient role: works today
  • Kong JWT plugin configuration: unchanged (same issuer)
  • tenantId / userType claims in tenant-claims scope: set identically for patient users

8. Security Notes

  1. Client secret rotation: ghasi-patient-web secret is rotated independently from ghasi-spa; stored in Kubernetes secrets / Vault with separate access policies.
  2. Blast radius: Patient credentials compromise is isolated — the patient role has no access to clinical API endpoints protected by @Roles() guards.
  3. No directAccessGrantsEnabled on patient clients — prevents password-spray attacks via the token endpoint.
  4. PKCE on ghasi-patient-web (even though confidential) adds defence-in-depth against auth-code interception in the browser.
  5. Mobile token leakage mitigated by PKCE (code interception) + SecureStore (token extraction from device storage).
  6. 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

#QuestionRecommended follow-up
9.1Per-client password policy — staff policy (length 12, special chars) is burdensome for patientsKeycloak 22+ Authentication Policy Override at the flow level → separate ADR
9.2No-email patients — patients with only a phone number cannot receive activation emailEvaluate Keycloak SMS Authenticator SPI for OTP delivery → separate ADR
9.3Cross-tenant patient identity — patient moves between clinicsPortalAccount already supports multiple records per idpSubject; explicitly test and document
9.4Patient self-registration — invite-token-based account activation without direct KC emailInvite 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).