Last updated: 2026-07-03
Covers: Keycloak OIDC login/logout flow, role-to-dashboard routing, multi-tenant domain awareness
The GP Booking App uses Keycloak as the single identity provider for all authentication. Django handles authorization (roles, permissions, sessions) but delegates password verification and SSO session management to Keycloak.
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Browser │────▶│ Nginx │────▶│ Django │
│ (User) │ │ (443→8000) │ │ Gunicorn │
└─────────────┘ └──────────────┘ └──────┬──────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ Keycloak │ │ PostgreSQL │
│ (auth) │ │ (multi-tenant) │
└──────────────┘ └──────────────────┘
veripath, test-client)gp-booking-app is configured in each realm with appropriate redirect_uris and client_secretThe Django app uses mozilla-django-oidc as the OIDC client library. Key settings in dsp_clinic/settings.py:
OIDC_RP_CLIENT_ID = 'gp-booking-app'
OIDC_RP_SCOPES = 'openid email profile'
OIDC_CREATE_USER = True
OIDC_STORE_ACCESS_TOKEN = True
OIDC_STORE_ID_TOKEN = True
LOGIN_URL = 'oidc_authentication_init'
LOGIN_REDIRECT_URL = 'tenancy:login_redirect'
users/oidc_views.py)TenantAwareOIDCRequestViewOverrides the OIDC authentication init endpoint (/oidc/authenticate/) to switch the Keycloak realm based on the request's subdomain:
class TenantAwareOIDCRequestView(OIDCAuthenticationRequestView):
def get(self, request):
if _is_partner_request(request):
realm = _get_partner_realm(request)
self.OIDC_OP_AUTH_ENDPOINT = (
f'https://auth.veripath.co.uk/realms/{realm}/protocol/openid-connect/auth'
)
elif _is_dental_request(request):
self.OIDC_RP_CLIENT_ID = DENTAL_CLIENT_ID
else:
self.OIDC_RP_CLIENT_ID = self.get_settings('OIDC_RP_CLIENT_ID')
return super().get(request)
Domain detection:
test-client.gp.veripath.co.uk): switches realm to test-clientdental.veripath.co.uk): switches client ID to dental-booking-appgp.veripath.co.uk): uses the veripath realmTenantAwareOIDCLogoutViewHandles logout at /oidc/logout/. Reads the oidc_id_token and oidc_realm from the session (stored at login time) to construct the correct Keycloak logout endpoint:
def _logout_url(self, request):
id_token = request.session.get("oidc_id_token")
realm = request.session.get("oidc_realm") # stored by KeycloakOIDCBackend
if realm:
logout_endpoint = f"https://auth.veripath.co.uk/realms/{realm}/protocol/openid-connect/logout"
else:
logout_endpoint = import_from_settings("OIDC_OP_LOGOUT_ENDPOINT")
params = {"post_logout_redirect_uri": redirect_url}
if id_token:
params["id_token_hint"] = id_token
...
def post(self, request):
id_token = request.session.get("oidc_id_token")
if not id_token:
auth.logout(request) # local logout only (password-authenticated user)
return HttpResponseRedirect(reverse("home"))
logout_url = self._logout_url(request)
auth.logout(request)
return HttpResponseRedirect(logout_url)
users/auth.py)KeycloakOIDCBackendExtends mozilla_django_oidc.auth.OIDCAuthenticationBackend to switch realms during the OIDC callback:
class KeycloakOIDCBackend(OIDCAuthenticationBackend):
def authenticate(self, request, **kwargs):
self.request = request
if _is_partner_request(request):
realm = _get_partner_realm(request)
# Switch token/userinfo endpoints to partner realm
self.OIDC_OP_TOKEN_ENDPOINT = _build_realm_url(realm, '/protocol/openid-connect/token')
self.OIDC_OP_USER_ENDPOINT = _build_realm_url(realm, '/protocol/openid-connect/userinfo')
self.OIDC_OP_JWKS_ENDPOINT = _build_realm_url(realm, '/protocol/openid-connect/certs')
request.session['oidc_realm'] = realm # stored for logout
...
return super().authenticate(request, **kwargs)
Also stores oidc_realm in the session so the logout view always uses the correct realm, regardless of which domain the user is on at logout time.
dashboards/routing.py)After successful OIDC login, Django redirects to tenancy:login_redirect which calls dashboard_redirect_response(request):
def dashboard_redirect_response(request):
role = getattr(request.user, 'role', None)
partner_org = getattr(request, 'partner_org', None)
if role == 'ADMIN' and partner_org:
return redirect(reverse('dashboards:superuser_dashboard'))
# ... falls through to get_dashboard_url()
ROLE_DASHBOARD_URL = {
'PATIENT': 'dashboards:patient_dashboard',
'CLINICIAN': 'dashboards:clinician_dashboard',
'RECEPTIONIST': 'dashboards:receptionist_dashboard',
'ADMIN': 'dashboards:compliance_admin_dashboard',
# ...
}
| User context | Destination |
|---|---|
Superuser on gp.veripath.co.uk |
/dashboard/compliance-admin/ |
| Admin on partner subdomain | /dashboard/admin/ |
| Receptionist on partner subdomain | /dashboard/receptionist/ |
TenantMiddleware (tenancy/middleware.py)Runs on every request. Parses the hostname to detect subdomain, looks up the corresponding Tenant and PartnerOrg records, and stores them on the request:
host = request.get_host().split(':')[0] # e.g. "test-client.gp.veripath.co.uk"
parts = host.split('.')
subdomain = parts[0] # e.g. "test-client"
Sets:
request.tenant — database tenant context (for SectorDatabaseRouter)request.partner_org — partner org context (for OIDC realm switching)set_current_tenant(tenant) — thread-local tenant for non-HTTP code pathsSectorDatabaseRouter (tenancy/routers.py)Routes database operations to the correct PostgreSQL database based on the current tenant's sector:
| App | Database |
|---|---|
tenancy, auth, admin, sessions, dashboards, partner |
default (primary VPS) |
users (with client tenant context) |
sector_client_{subdomain} |
onboarding, appointments, clinical_data |
Client tenant DB or sector_gp |
dental |
sector_dental |
SESSION_COOKIE_DOMAIN = '.gp.veripath.co.uk'
SESSION_COOKIE_AGE = 86400 # 24 hours
SESSION_SAVE_EVERY_REQUEST = True
The session cookie is shared across all *.gp.veripath.co.uk subdomains. This allows an admin to log in on test-client.gp.veripath.co.uk and then be redirected to gp.veripath.co.uk with their session intact.
Primary VPS (88.208.212.211) Client VPS (isolated)
┌──────────────────────────┐ ┌────────────────────┐
│ Keycloak │ │ │
│ Django + Gunicorn │◄────────► PostgreSQL │
│ Nginx │ WG │ (client data) │
│ Session (Cookie shared) │ │ │
│ Admin/Superuser users │ │ Patient users │
│ Partner org records │ │ Appointments │
└──────────────────────────┘ │ Clinical notes │
└────────────────────┘
Principle: The app and auth live on the primary VPS. Client data lives on isolated client VPS databases, accessed through WireGuard tunnels. The same Django app serves all domains, using the SectorDatabaseRouter to route to the correct database.
| Path | View | Purpose |
|---|---|---|
/oidc/authenticate/ |
TenantAwareOIDCRequestView |
Initiate Keycloak login |
/oidc/callback/ |
OIDCAuthenticationCallbackView |
Keycloak callback (POST) |
/oidc/logout/ |
TenantAwareOIDCLogoutView |
End Django + Keycloak session |
/accounts/login/ |
Django LoginView |
Legacy password form (redirects to /oidc/authenticate/) |
/admin/ |
Django admin | System administration (superusers only) |