In verteilten Architekturmodellen, wie Microservices oder Self-contained Systems (SCS), wird eine Anwendung aus mehreren, unabhängigen Systemen erstellt. Das hat viele bekannte Vorteile (optimierbare Anwendungen, unabhängige Teams, Wartbarkeit, Skalierbarkeit, …), aber auch eine Herausforderung: Wie funktioniert eine übergreifende Authentifizierung? Schließlich will sich der Benutzer nicht in jedem Teil-System neu registrieren und einloggen müssen.

Single Sign-On mit OpenID Connect

Die Idee: Es gibt ein System, welches sich ausschließlich um die Registrierung und die Authentifizierung eines Benutzers kümmert (Single Sign-On). Dieser Identity Provider stellt bei einer erfolgreichen Authentifizierung ein Token aus und alle anderen Systeme (Relying Parties) vertrauen diesem Token.

Das passende Protokoll hierfür ist OpenID Connect, das auf den Standards OAuth 2.0 und JWT basiert. OpenID Connect wurde ursprünglich für Social Logins entwickelt, um sich mit Facebook, Google oder Twitter auf einer fremden Webseite einzuloggen. Im Enterprise Umfeld ist es aber aus Datenschutz- und Sicherheitsgründen allgemein üblich, seinen eigenen Identity Provider zu verwenden, was mit OpenID Connect ebenfalls möglich ist.

Ein verbreiteter Authorization und Identity Server mit sehr guter OpenID Connect Unterstützung ist Keycloak (Open Source). Es gibt zudem einige weitere Produkte und Cloud-Anbieter.

Redirects, Refresh, Verification

Im Detail ist der OpenID Connect Authorization Code Flow leider relativ aufwendig:

OpenID Connect Authorization Code Flow
OpenID Connect Authorization Code Flow

Wenn ein registrierter Benutzer auf eine geschützte Ressource einer Anwendung zugreifen möchte (1), dann wird der Benutzer auf die Login-Maske des Identity Providers weitergeleitet (2). Dies geschieht über HTTP Redirects. Der Benutzer wird aufgefordert, sich einzuloggen (3) und gibt seine Credentials ein (4). Nach dem erfolgreichen Login (5) erfolgt ein weiterer Redirect zur Anwendung (6), bei der ein Authentication-Code mitgegeben wird.

Mit diesem Code kann die Anwendung dann ein Access-Token, ein ID-Token und das langlebige Refresh-Token anfordern (7), vgl. folgenden Beispiel-Aufruf. Das ID-Token liegt als JWT vor und beinhaltet die relevanten Benutzer-Informationen, wie Benutzername, Kundennummer und ggf. auch Berechtigungen.

POST /token HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

{
"access_token": "SlAV32hkKG",
"token_type": "Bearer",
"refresh_token": "8xLOxBtZp8",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkazcifQ.ewogImlzcyI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4Mjg5NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAibi0wUzZfV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEzMTEyODA5NzAKfQ.ggW8hZ1EuVLuxNuuIJKX_V8a_OMXzR0EHR9R6jgdqrOOF4daGU96Sr_P6qJp6IcmD3HP99Obi1PRs-cwh3LO-p146waJ8IhehcwL7F09JdijmBqkvPeB2T9CJNqeGpe-gccMg4vfKjkM8FcGvnzZUN4_KSP0aAp1tOJ1zZwgjxqGByKHiOtX7TpdQyHE5lcMiKPXfEIQILVq0pc_E2DzL7emopWoaoZTF_m0_N0YzFC6g6EJbOEoRoSK5hoDalrcvRYLSrQAZZKflyuVCyixEoV9GfNQC3_osjzw2PAithfubEEBLuVVk4XUVrWOLrLl0nx7RkKU8NXNHq-rvKMzqg"
}
Beispiel Token Request (8)

Der große Vorteil ist dabei, dass das JWT vom Identity Provider signiert wurde und die Signatur effizient ohne einen Remote-Aufruf zum Identity Provider verifiziert werden kann, wenn das Shared-Secret (im HMAC-Verfahren) oder (besser!) der Public Key (im RS-Verfahren) der Relying Party bekannt ist, wobei diese den Public Key auch abrufen kann (8). Nach erfolgreicher Validierung des JWT (9), kann die Anwendung die Benutzerinformationen wie Benutzername, E-Mail-Adresse und Rollen aus dem Token auslesen.

// 1. Header:
{
  "alg": "RS256",
  "kid": "1e9gdk7"
}

// 2. Payload:
{
  "iss": "http://server.example.com",
  "sub": "248289761001",
  "aud": "s6BhdRkqt3",
  "nonce": "n-0S6_WzA2Mj",
  "exp": 1311281970,
  "iat": 1311280970
}

// 3. RS256-Signatur aus Header und Payload
Dekodiertes ID-Token

Das Access-Token ist relativ kurzlebig (einige Minuten) und mit dem langlebigen Refresh-Token kann die Anwendung kurz vor Ablauf ein neues Access-Token anfordern (10). Dabei wird geprüft, dass die Session noch aktiv ist und der Account nicht gesperrt wurde (hier findet wieder ein Remote-Aufruf zum Identity Provider statt).

Die Prüfung, ob der authentifizierte Benutzer auf eine Ressource zugreifen darf, ist Aufgabe der Fachanwendung, wobei die zugewiesenen Rollen im ID-Token enthalten sein können.

Single Sign-Out

Da das Access-Token eine bestimmte Zeit gültig ist, ist ein frühzeitiger, systemübergreifender Logout nicht ohne Weiteres möglich.

Falls diese Anforderung besteht, muss die Session im Identity Provider invalidiert und die Access-Tokens bei allen Relying Parties gelöscht werden. OpenID Connect sieht hier die Spezifikationserweiterungen Session Management, Front-Channel Logout und Back-Channel Logout vor, die unter anderem auf IFrames und Logout-Token basieren.

Eine weitere Alternative: Vor einer kritischen Operation überprüft die Relying Party beim Identity Provider, ob das Access-Token noch gültig ist. Dies kann z. B. über den UserInfo-Endpoint durchgeführt werden.

Authentication Proxy

Die korrekte Umsetzung des Authentication Flows muss jede Relying Party sicherstellen. Damit dies nicht mehrfach implementiert werden muss (ggf. sogar in unterschiedlichen Plattformen), bietet es sich ein vorgeschalteter Authentication Proxy an. Dieser kümmert sich um Redirect, Refresh und Validierung und lässt nur authentifizierte Anfragen an die Fachanwendung durch. Dabei werden die Authentifizierungsinformationen und das Access-Token mit übergeben (typischerweise als Header-Felder). Die Anwendung kann sich damit auf die Umsetzung der Fachlichkeit konzentrieren. Im Container/Kubernetes-Umfeld verwendet man diesen Proxy als Sidecar.

Nach meinem Wissen ist lua-resty-openidc als NGINX Erweiterung hierfür derzeit am besten geeignet. Besonders hervorzuheben ist die Leeway-Unterstützung, also dass das Access-Token ein paar Sekunden vor Ablauf erneuert wird, sodass Folge-Aufrufe nicht plötzlich ein abgelaufenes Access-Token bekommen. Andere Implementierungen beschränken sich auf Social-Logins, haben keine Unterstützung für Refresh-Tokens oder akzeptieren none als Signatur-Algorithmus (Security-GAU!).

Schauen wir uns ein Beispiel für die Konfiguration an:

events {
  worker_connections 128;
}

http {

  lua_package_path '~/lua/?.lua;;';

  # docker embedded DNS server 
  resolver 127.0.0.11 ipv6=off;

  lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
  lua_ssl_verify_depth 5;

  # cache for discovery metadata documents
  lua_shared_dict discovery 1m;
  # cache for JWKs
  lua_shared_dict jwks 1m;

  server {
    listen 80;

    location /app1 {
        access_by_lua_block {
            local opts = {
                redirect_uri_path = "/app1/redirect_uri",
                discovery = "http://keycloak:8080/auth/realms/myapp/.well-known/openid-configuration",
                client_id = "app1",
                client_secret = "b162ec35-3e05-4129-8da1-63d5e721b7d6",
                scope = "openid email",
                access_token_expires_leeway = 30,
                -- This is really, really important
                accept_none_alg = false,
                accept_unsupported_alg = false,
                renew_access_token_on_expiry = true,
                session_contents = {access_token=true, id_token=true}
            }

          -- call authenticate for OpenID Connect user authentication
          local res, err = require("resty.openidc").authenticate(opts)

          if err then
            ngx.status = 500
            ngx.say(err)
            ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
          end

          -- Set valid access token and email as request header
          ngx.req.set_header("Authorization", "Bearer " .. res.access_token)
          ngx.req.set_header("X-User", res.id_token.email)
      }

      proxy_pass http://app1:8090;
    }
  }
}
nginx.conf

Im Wesentlichen wird hier die Discovery URL und die Client Credentials konfiguriert.

Der Proxy kümmert sich also um Redirect-Handling, Token-Refresh und Validierung der Tokens. Alle Session-Daten (Nonce, State, Redirect-URLs, Access-Token, ID-Token, Refresh-Token) werden hier übrigens in einem verschlüsselten Session-Cookie gespeichert. Load-Balancing-Probleme sollten wir also nicht haben. Nachteil: Die Request Größe steigt aufgrund des Cookies pro Request um etwa 4 KB. Alternativen wie Redis wären aber konfigurierbar.

Self-contained Systems mit Auth Proxy
Self-contained Systems mit Auth Proxy

Das gültige Access-Token (das hier dem ID-Token entspricht) wird an die Fachanwendung als Bearer-Token im Header Authorization weitergegeben. Außerdem wird in diesem Beispiel die E-Mail-Adresse aus dem Claim email direkt in den Header X-User geschrieben. Damit müsste die Fachanwendung keine Unterstützung für JWT haben, sondern kann den Principal einfach aus dem Header-Feld lesen.

Eine weitere Überlegung: Verwendet man einen zentralen Auth-Proxy für alle Anwendungen oder soll jede Anwendung einen eigenen Auth-Proxy einsetzen? Beides ist möglich. Die Entscheidung sollte zur Macro-Architektur und der Infrastruktur passen.

Spring Security UserAuthenticationConverter

Ich verwende für Fachanwendungen häufig Spring Boot. Die Unterstützung für OAuth2 Resource Server und Bearer Tokens ist durch spring-security-oauth bzw. Backport für Spring Boot 2 gegeben. Die native OpenID Connect Unterstützung lässt leider noch auf etwas auf sich warten.

Standardmäßig mappt spring-security-oauth aus dem JWT den Claim user_name als Spring Security Principal. Da in OpenID Connect hierfür aber der Claim sub vorgesehen ist, ist ein spezifischer UserAuthenticationConverter sinnvoll:

public class OidcSubjectUserAuthenticationConverter implements UserAuthenticationConverter {

    private static final String SUBJECT_CLAIM = "sub";

    @Override
    public Authentication extractAuthentication(Map<String, ?> map) {
        if (map.containsKey(SUBJECT_CLAIM)) {
            Object principal = map.get(SUBJECT_CLAIM);
            return new UsernamePasswordAuthenticationToken(principal, "N/A", Collections.emptyList());
        }
        return null;
    }

    @Override
    public Map<String, ?> convertUserAuthentication(Authentication authentication) {
        throw new UnsupportedOperationException("convertUserAuthentication is not supported");
    }

}
OidcSubjectUserAuthenticationConverter

Source Code

Ein komplettes Beispiel findet sich in Github: https://github.com/jochenchrist/auth-proxy