Sociale login en OIDC (Backend)
Doel van de module
Remedice ondersteunt inloggen via externe OpenID Connect-providers: ZORG-ID, Google en Apple. Eén generieke OIDC-engine voert het protocol uit, met per provider een dunne claim-mapper die de provider-specifieke claims vertaalt naar een genormaliseerde identiteit. De engine is discovery-gedreven, zodat endpoints niet hardgecodeerd zijn.
De koppeling tussen een externe identiteit en een Remedice-account loopt over de stabiele provider-subject, nooit over een e-mailadres. Een externe provider is daarnaast nooit een tweede factor. Inloggen bewijst de identiteit, en gevoelige acties vragen daarnaast om een TOTP- of biometrie-step-up.
Technisch Ontwerp
Eén generieke engine in common/oidc/core.py voert voor elke provider dezelfde Authorization Code Flow met PKCE (S256), state en nonce uit. De provider-endpoints komen uit diens .well-known-configuratie, dus ze staan niet hardgecodeerd.
De code is verdeeld over drie lagen. common/oidc/core.py is de protocol-engine. De browser-handshake (authorize en callback) leeft in auth/views/zorgid.py met de generieke views OidcAuthorizeView en OidcCallbackView. De app-gerichte JSON-endpoints (code-exchange, koppelen, ontkoppelen, de native Apple-route) leven in auth/views/oauth.py.
flowchart TD
A[App of browser: authorize-endpoint per provider] --> B[OidcAuthorizeView<br/>build_authorization_request: PKCE S256 + state + nonce]
B --> C[302 naar het autorisatie-endpoint van de provider<br/>web krijgt een binding-cookie]
C --> D[Gebruiker authenticeert bij de provider]
D --> E[Redirect naar callback met code en state]
E --> F[OidcCallbackView._complete_callback<br/>state en binding-cookie controleren]
F --> G[oidc.exchange_code<br/>token ophalen, ID-token valideren, userinfo waar de provider die kent]
G --> H[claim-mapper per provider<br/>genormaliseerde identiteit met stabiele subject]
H --> I{Subject al gekoppeld?}
I -- Ja --> J[complete_login mint een Remedice-JWT<br/>geleverd via een eenmalige exchange-code]
I -- Nee --> K[Eenmalig link-ticket<br/>app vraagt e-mail en wachtwoord]
K --> L[OauthLinkLoginView bindt de subject aan het bewezen account]
Identiteit per provider
Elke provider levert een eigen claim-mapper die de claims vertaalt naar één stabiele subject. De koppeling aan een Remedice-account keyt altijd op die subject, nooit op het e-mailadres.
- ZORG-ID (
common/oidc/zorgid.py): de gebruiker authenticeert met de ZORG-ID Mobile App. Desubjectis de userinfo-username, het laatste.-segment vansub. Volgens de ZORG-ID-specificatie is dit een UZI-nummer of een ander uniek nummer. De userinfo moetactive == Open(status 3) melden, anders wordt de login geweigerd. Naam, rol en het meegestuurde certificaat worden niet gelezen of opgeslagen (dataminimalisatie). - Google (
common/oidc/google.py): desubjectis de Google-subuit het gevalideerde ID-token. Het e-mailadres is bewust geen sleutel, omdat het kan wijzigen of onbevestigd kan zijn. Naam en e-mail zijn alleen voor weergave. - Apple (
common/oidc/apple.py): desubjectis de Apple-sub, stabiel per gebruiker en team. Apple heeft geen userinfo-endpoint, dus de identiteit komt volledig uit het ID-token, en het clientsecret is een kortlevend ES256-JWT in plaats van een vaste string. De native iOS-route verifieert het Apple-ID-token direct, zonder code-uitwisseling, en vergelijkt de nonce als SHA-256-hash.
Browser-handshake (sequence)
De browser-handshake verloopt voor elke provider via dezelfde stappen. Onderstaand de ZORG-ID-variant als voorbeeld. Google doorloopt dezelfde stappen, de native Apple-route slaat de code-uitwisseling over en verifieert het ID-token direct (zie Identiteit per provider).
sequenceDiagram
participant B as Browser
participant API as Remedice API
participant P as Provider (hier ZORG-ID)
B->>API: GET authorize-endpoint
API->>API: state, nonce en PKCE (S256) genereren en server-side cachen
API-->>B: 302 naar autorisatie-endpoint, binding-cookie op web
B->>P: Authenticatie bij de provider (ZORG-ID Mobile App)
P-->>B: 302 naar callback met code en state
B->>API: GET callback met code en state
API->>API: state en binding-cookie controleren (eenmalig)
API->>P: POST token-endpoint (client_secret_post + code_verifier)
P-->>API: id_token en access_token
API->>API: ID-token valideren (RS256 via JWKS, iss/aud/exp/iat/nonce/azp)
API->>P: GET userinfo met Bearer access_token (waar de provider die kent)
P-->>API: sub, active, certificates
API->>API: active == Open controleren, subject uit sub halen
API-->>B: 302 terug naar de app met eenmalige code of link-ticket
Datamodel (ERD)
Een gekoppelde identiteit staat in UserOidcIdentity. De subject (voor ZORG-ID het nummer uit sub, voor andere providers de provider-sub) is identificerende PII en wordt versleuteld opgeslagen. Omdat een versleutelde kolom niet indexeerbaar is, loopt uniciteit en lookup over subject_hash, een SHA-256 van provider:subject.
erDiagram
User ||--o{ UserOidcIdentity : "oidc_identities"
User {
bigint id PK
string email
boolean is_active
datetime deleted_at
}
UserOidcIdentity {
bigint id PK
bigint user_id FK
string provider
string subject "versleuteld"
string subject_hash "SHA-256(provider:subject)"
datetime created_at
}
Twee unieke constraints leggen de bindingsregels vast op databaseniveau:
uniq_oidc_identityop(provider, subject_hash): één externe identiteit bindt aan precies één Remedice-account.uniq_user_provider_identityop(user, provider): een account heeft hooguit één identiteit per provider.
Het verwijderen van een account verwijdert de gekoppelde rijen (cascade), zodat een plain unique correct is.
API & Communicatie
Browser-handshake. Deze endpoints redirecten en staan bewust niet in het OpenAPI-schema.
GET /api/auth/zorgid/authorize/: start de ZORG-ID-login. De generieke variant isGET /api/auth/oauth/<provider>/authorize/.GETofPOST /api/auth/zorgid/callback/: het bij VZVZ geregistreerderedirect_uri. POST is nodig voor Apple, datresponse_mode=form_postafdwingt.
App-gerichte JSON-endpoints (in het Swagger-schema onderaan):
POST /api/auth/oauth/exchange/: wisselt de eenmalige callback-code voor het loginresultaat (tokens of tenant-keuze).POST /api/auth/oauth/link-login/: bindt een nog ongekoppelde identiteit aan het account na bewijs met e-mail en wachtwoord.POST /api/auth/oauth/link/start/: start het koppelen van een provider aan het huidige account (step-up vereist).GET /api/auth/oauth/identities/: lijst van gekoppelde providers.DELETE /api/auth/oauth/identities/{provider}/: ontkoppelt een provider (step-up vereist).POST /api/auth/oauth/apple/native/en.../native/link/: de native iOS Sign in with Apple-route, die het Apple-ID-token direct verifieert.
De callback levert nooit tokens in een redirect-URL. Het loginresultaat wordt onder een eenmalige, kortlevende code in de cache gezet. De app wisselt die code in via exchange.
Foutafhandeling & Statuscodes
400: een kapotte of verlopen callback (ontbrekende of verlopenstate) of een niet-kloppende binding-cookie. Dit zijn CSRF- en sessie-randgevallen waarvan het platform niet bekend is.- Verificatiefout nadat provider en platform bekend zijn: een mislukte code-uitwisseling, een niet-kloppende
nonce, een ZORG-ID-sessie die niet Open is (active != 3), of een lege identiteit levert geen rauwe JSON op, maar een redirect terug naar de app met een generieke foutstatus en de providernaam. De frontend toont dan een providergenoemde toast, bijvoorbeeld "Inloggen met ZORG-ID is niet gelukt. Probeer het opnieuw." 401: ongeldige inloggegevens op de koppel-login. De melding is uniform, zodat niet valt af te leiden of het account of de identiteit bestaat.409: de Apple-identiteit is bij het native koppelen al aan een ander account gebonden.429: accountvergrendeling na te veel mislukte pogingen, of de rate-limit van het endpoint.503: de provider is niet geconfigureerd of tijdelijk onbereikbaar. Een circuit breaker telt mislukte calls naar de provider en zet na herhaalde fouten binnen een kort tijdvenster een outage-vlag. Zolang die vlag staat faalt de engine direct met een nette melding, in plaats van opnieuw in een timeout te lopen of een 500 te geven. Een geslaagde call wist de teller en de vlag, dus de breaker herstelt vanzelf zodra de provider weer reageert.
Autorisatie & Beveiliging
- Authorization Code Flow met PKCE (S256). Bij ZORG-ID is PKCE niet verplicht in de spec, maar de engine stuurt altijd zowel de
code_challengeals decode_verifier, waardoor de flow niet kan breken ongeacht of de server PKCE handhaaft. statebeschermt de callback tegen CSRF en is eenmalig. Voor web-login is destatedaarnaast aan de browser gebonden via een korte, httpOnly, secure cookie. Native is al gebonden door de eenmalige code die via deremedice://deeplink naar precies de juiste app gaat.noncewordt gevalideerd in het ID-token. Voor de native Apple-route hasht de backend de rauwe nonce met SHA-256 voordat hij vergelijkt, omdat Apple de hash van de nonce teruggeeft.- ID-token-validatie: RS256 via de JWKS, met verplichte
iss,aud,expeniat, plus controle vannonceenazp. Dekidhaalt de juiste sleutel op, met automatische JWKS-refresh bij sleutelrotatie. - Bij providers met een userinfo-endpoint moet de
subuit userinfo gelijk zijn aan desubuit het ID-token. Een verschil betekent dat de tokens een andere principal beschrijven en de login wordt geweigerd. - Tokenendpoint-authenticatie is
client_secret_post. Het clientsecret is een echte secret en staat niet in de code of in git. - De
subjectwordt versleuteld opgeslagen. De koppeling keyt op de stabielesubject, nooit op het e-mailadres. - Een externe provider is nooit een tweede factor. Koppelen en ontkoppelen vanuit het profiel vereisen een verse step-up. De koppel-startview bakt het user-id in de server-side
state, zodat een vervalstestategeen identiteit op een ander account kan enten. - Throttling op authorize, callback, exchange en de koppel-endpoints. Accountvergrendeling op de koppel-login.
ZORG-ID specifiek
- Authenticatie loopt via de ZORG-ID Web-modus met de Mobile App. De UZI-pas en de ZORG-ID Smartcard vereisen de lokale ZORG-ID SDK en vallen buiten deze OIDC-integratie.
- Scopes:
openid profile api. We vragen bewust geenoffline_access(refresh token): na login mint de engine een eigen Remedice-JWT en praat niet meer met ZORG-ID, dus de ZORG-ID-sessie is eenmalig voor de login. - De userinfo moet
active == Open(status 3) melden. Andere sessiestatussen worden geweigerd. De bindingssleutel en dataminimalisatie staan onder Identiteit per provider. - De egress moet de ZORG-ID firewallhost bereiken. Acceptatie en productie hebben elk een eigen vaste host.
Bestandsstructuur & Verantwoordelijkheden
backend/common/oidc/core.py: de protocol-engine. Authorize-request bouwen, code uitwisselen, ID-token valideren, userinfo ophalen, circuit breaker.backend/common/oidc/providers.py: de provider-registry en deOidcProviderConfigdie per provider uit settings wordt opgebouwd.backend/common/oidc/zorgid.py: de ZORG-ID claim-mapper. Het nummer uitsuben de active-controle.backend/common/oidc/google.pyenbackend/common/oidc/apple.py: de claim-mappers voor Google en Apple, plus het minten van het Apple-clientsecret en de native tokenverificatie.backend/auth/views/zorgid.py: de browser-handshake,OidcAuthorizeViewenOidcCallbackView, en het dispatchen van een gevalideerde identiteit naar login of koppeling.backend/auth/views/oauth.py: de app-gerichte JSON-endpoints voor exchange, koppel-login, koppelen, ontkoppelen en de native Apple-route.backend/auth/services/oauth_identity.py: koppelen, ontkoppelen en resolven van(provider, subject)naar een user.backend/auth/services/oauth_bridge.py: de eenmalige cache-brug voor het loginresultaat en het koppel-ticket.backend/auth/models.py: het modelUserOidcIdentityen de hashfunctieoidc_subject_hash.backend/common/auth_cookies.py: de cookie-helpers, waaronder de binding-cookie voor de web-login.
Belangrijke bestanden
backend/common/oidc/core.pybackend/common/oidc/providers.pybackend/common/oidc/zorgid.pybackend/auth/views/zorgid.pybackend/auth/views/oauth.pybackend/auth/services/oauth_identity.pybackend/auth/models.py