Ga naar inhoud

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. De subject is de userinfo-username, het laatste .-segment van sub. Volgens de ZORG-ID-specificatie is dit een UZI-nummer of een ander uniek nummer. De userinfo moet active == 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): de subject is de Google-sub uit 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): de subject is 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_identity op (provider, subject_hash): één externe identiteit bindt aan precies één Remedice-account.
  • uniq_user_provider_identity op (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 is GET /api/auth/oauth/<provider>/authorize/.
  • GET of POST /api/auth/zorgid/callback/: het bij VZVZ geregistreerde redirect_uri. POST is nodig voor Apple, dat response_mode=form_post afdwingt.

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 verlopen state) 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_challenge als de code_verifier, waardoor de flow niet kan breken ongeacht of de server PKCE handhaaft.
  • state beschermt de callback tegen CSRF en is eenmalig. Voor web-login is de state daarnaast aan de browser gebonden via een korte, httpOnly, secure cookie. Native is al gebonden door de eenmalige code die via de remedice:// deeplink naar precies de juiste app gaat.
  • nonce wordt 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, exp en iat, plus controle van nonce en azp. De kid haalt de juiste sleutel op, met automatische JWKS-refresh bij sleutelrotatie.
  • Bij providers met een userinfo-endpoint moet de sub uit userinfo gelijk zijn aan de sub uit 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 subject wordt versleuteld opgeslagen. De koppeling keyt op de stabiele subject, 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 vervalste state geen 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 geen offline_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 de OidcProviderConfig die per provider uit settings wordt opgebouwd.
  • backend/common/oidc/zorgid.py: de ZORG-ID claim-mapper. Het nummer uit sub en de active-controle.
  • backend/common/oidc/google.py en backend/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, OidcAuthorizeView en OidcCallbackView, 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 model UserOidcIdentity en de hashfunctie oidc_subject_hash.
  • backend/common/auth_cookies.py: de cookie-helpers, waaronder de binding-cookie voor de web-login.

Belangrijke bestanden

  • backend/common/oidc/core.py
  • backend/common/oidc/providers.py
  • backend/common/oidc/zorgid.py
  • backend/auth/views/zorgid.py
  • backend/auth/views/oauth.py
  • backend/auth/services/oauth_identity.py
  • backend/auth/models.py

API & Communicatie (Swagger)