gRPC Tutorial Teil 7: JWT Client Credentials mit Rollen und IdentityServer4
Im letzten Blog wurde der einfachste OAuth2-Flow, der Client Credential Flow, anhand eines Beispiels unter Verwendung eines gRPC-Clients / -Servers mit einem minimalistischen Token-Server erläutert. In diesem Blog wird der Token Server implementiert mit IdentityServer4. Es wird das Public-Key-Verschlüsselungsverfahren verwendet und rollenbasierte Autorisierung.
Der Beispielcode kann hier als ZIP-Datei heruntergeladen werden.
Ablauf
Das Konstrukt involviert drei Teilnehmer: den Client, den Identity Server und den Ressourcen-Server.
Der Ablauf ist wie folgt:
- Der Client holt sich beim Discovery Endpoint des Identity Server den Token Endpoint.
- Mit seiner ClientId, dem Secret und dem Namen des gewünschten APIs fragt der Client ein JWT Token beim Token Endpoint des Identity Servers an.
- Der Identity Server prüft die ClientId, das Client Secret und ob der Client mit der gewünschten API kommunizieren darf. Der Identity Server definiert die Autorisierungsrolle des Clients und packt diese in das Token.
- Der Client speichert das Token und sendet es bei jeder Anfrage am Ressourcen-Server mit (im Header).
- Der Ressourcen-Server validiert das Token und kontrolliert die Signatur. Wenn das Token gültig ist, wird die Rolle aus dem Token gelesen und kontrolliert, ob der Client berechtigt ist, die spezifische Anfrage zu machen.
Die ganze Kommunikation verläuft über eine gesicherte Verbindung, weil der Besitzer des Tokens direkt auf die Ressource zugreifen kann.
Identity Server 4
Identity Server 4 ist ein OpenID Connect- und OAuth 2.0-Framework für .NET, .NET Core und ASP.NET Core, um JWT Bearer Tokens zu generieren. Identity Server 4 ist eine Implementierung der OAuth 2.0-Spezifikation und unterstützt die Standard OAuth 2.0 ‘Flows’ (siehe https://blog.noser.com/grpc-tutorial-teil-6-jwt-client-credentials). Die Webseiten des Identity Server 4 sind sehr ausführlich und beinhalten einfache Beispiele, unter anderen vom Client Credential Flow (https://identityserver4.readthedocs.io/en/latest/quickstarts/1_client_credentials.html).
Beispiel: Identity-Server
Das Identity-Server Projekt im Beispiel heisst RpcJwtClientCredentialsRolesIdentityServer und referenziert das NuGet Paket IdentityServer4 (version 3.0.1).
Das Projekt benutzt den Kestrel-Server mit gesicherter Verbindung wie beschrieben in ‘gRPC Tutorial Teil 4: HTTP/2 über HTTPS’.
Die relevanten Parameter in appsettings.json sehen wie folgt aus:
… "ApiResource": { "Name": "DemoService", "DisplayName": "gRPC DemoService API" }, "ClientUser": { "ClientId": "ClientUser", "ClientSecret": "D9B31A4E97C40EC47D758490C801FFCDD39BCE3EF5E9C978E4FF9D34FA0F1967", "AllowedScopes": "DemoService", "Role": "User" }, "ClientAdmin": { "ClientId": "ClientAdmin", "ClientSecret": "V4YH2Q8C968XC52S5EFWD7VDX3TBEU9ZNY8EE6GE2F35ATM5XSS3PD6C6PEA9DZM", "AllowedScopes": "DemoService", "Role": "Admin" }, "Certificate": { "File": "server.pfx", "Password": "P@ssw0rd" } …
In der Methode ConfigureServices() in Startup.cs wird der Identity-Server konfiguriert.
Als erstes wird ein Array erstellt mit den APIs, hier nur DemoService:
IEnumerable<ApiResource> apis = new ApiResource[] { new ApiResource(Configuration["ApiResource:Name"], Configuration["ApiResource:DisplayName"]) };
Dann wird ein Array mit zwei Clients definiert, einen mit User- und einen mit Admin-Rechten:
IEnumerable<Client> clients = new Client[] { // Client User new Client { ClientId = Configuration["ClientUser:ClientId"], AllowedGrantTypes = GrantTypes.ClientCredentials, ClientSecrets = { new Secret(Configuration["ClientUser:ClientSecret"].Sha256()) }, AllowedScopes = { Configuration["ClientUser:AllowedScopes"] }, Claims = new List<Claim> { new Claim(JwtClaimTypes.Role, Configuration["ClientUser:Role"]) } ,ClientClaimsPrefix = null }, // Client Admin new Client { ClientId = Configuration["ClientAdmin:ClientId"], AllowedGrantTypes = GrantTypes.ClientCredentials, ClientSecrets = { new Secret(Configuration["ClientAdmin:ClientSecret"].Sha256()) }, AllowedScopes = { Configuration["ClientAdmin:AllowedScopes"] }, Claims = new List<Claim> { new Claim(JwtClaimTypes.Role, Configuration["ClientAdmin:Role"]) } ,ClientClaimsPrefix = null } };
Wenn ein Client ein Token anfragt, dann sendet dieser seine ClientId, Secret und gewünschte API mit. Die ClientId wird benutzt, um in den ‘clients’-Array die Daten aufzusuchen. Das ClientSecret wird verglichen und es wird kontrolliert, ob der Client auf die gewünschte API zugreifen darf. Die definierte Rolle wird im Token verpackt.
Der Identity-Server hört in diesem Projekt auf https://localhost:5001 (eingestellt in ‘launchSettings.json’). Man kann unter der URL https://localhost:5001/.well-known/openid-configuration ein ‘discovery’-Dokument beim Server abfragen, worin sich die URL des Token-Endpoints befindet (token_endpoint: „https://localhost:5001/connect/token„).
{ issuer: "https://localhost:5001", jwks_uri: "https://localhost:5001/.well-known/openid-configuration/jwks", authorization_endpoint: "https://localhost:5001/connect/authorize", token_endpoint: "https://localhost:5001/connect/token", userinfo_endpoint: "https://localhost:5001/connect/userinfo", end_session_endpoint: "https://localhost:5001/connect/endsession", check_session_iframe: "https://localhost:5001/connect/checksession", revocation_endpoint: "https://localhost:5001/connect/revocation", introspection_endpoint: "https://localhost:5001/connect/introspect", device_authorization_endpoint: "https://localhost:5001/connect/deviceauthorization", frontchannel_logout_supported: true, frontchannel_logout_session_supported: true, backchannel_logout_supported: true, backchannel_logout_session_supported: true, scopes_supported: [ "DemoService", "offline_access" ], claims_supported: [ ], grant_types_supported: [ "authorization_code", "client_credentials", …
Beispiel: Client
Das Client-Projekt im Beispiel heisst RpcJwtClientCredentialsRolesClient. Die relevanten Parameter in appsettings.json sehen wie folgt aus:
"IdentityServer": { "Authority": "https://localhost:5001" }, "ClientUser": { "ClientId": "ClientUser", "ClientSecret": "D9B31A4E97C40EC47D758490C801FFCDD39BCE3EF5E9C978E4FF9D34FA0F1967", "Audience": "DemoService" }, "ClientAdmin": { "ClientId": "ClientAdmin", "ClientSecret": "V4YH2Q8C968XC52S5EFWD7VDX3TBEU9ZNY8EE6GE2F35ATM5XSS3PD6C6PEA9DZM", "Audience": "DemoService" }, "Service": { "CustomerId": 1, "DelayInterval": 1000, "ServiceUrl": "https://localhost:5002" }
In Worker.cs ist eine Methode ‘GetToken()’ definiert. Als erstes holt sich diese Methode das ‘Discovery’-Dokument beim Identity-Server:
using var identityClient = new HttpClient(); var discover = await identityClient.GetDiscoveryDocumentAsync(_config["IdentityServer:Authority"]).ConfigureAwait(false); if (discover.IsError) { _logger.LogError(discover.Error); return false; }
Wenn das gelungen ist, extrahiert der Client den Token-Endpoint und sendet einen Request für ein Token ab:
ClientCredentialsTokenRequest tokenRequest = new ClientCredentialsTokenRequest() { Address = discover.TokenEndpoint, ClientId = _config["ClientAdmin:ClientId"], ClientSecret = _config["ClientAdmin:ClientSecret"], Scope = _config["ClientAdmin:Audience"] }; var tokenResponse = await identityClient.RequestClientCredentialsTokenAsync(tokenRequest).ConfigureAwait(false); if (tokenResponse.IsError) { _logger.LogError(tokenResponse.Error); return false; } _logger.LogInformation(tokenResponse.Json.ToString()); _token = tokenResponse.AccessToken;
Das JWT-Token wird gespeichert und sieht für ClientAdmin wie folgt aus:
gRPC with IdentityServer4 JWT-TokenDer gRPC-Client wird wie üblich erstellt, nur wird bei jeder Anfrage das Token im Header mitgesendet:
NameMessage nameMessage = new NameMessage { Name = $"{Assembly.GetExecutingAssembly().FullName}" }; string tokenValue = $"Bearer {_token}"; var metadata = new Metadata() { { "Authorization", tokenValue } }; CallOptions callOptions = new CallOptions(metadata); var result = await _client.ExchangeNamesAsync(nameMessage, callOptions);
Beispiel: Ressourcen-Server
Das Client-Projekt im Beispiel heisst RpcJwtClientCredentialsRolesServer. Das Projekt benutzt ebenfalls den Kestrel-Server mit gesicherter Verbindung wie beschrieben in ‘gRPC Tutorial Teil 4: HTTP/2 über HTTPS’.
Die relevanten Parameter in appsettings.json sehen wie folgt aus:
"JwtBearerAuth": { "Audience": "DemoService", "Authority": "https://localhost:5001" }, "Certificate": { "File": "server.pfx", "Password": "P@ssw0rd" }
Die JwtBearer Authentication wird in Startup.cs wie folgt konfiguriert. Der Code ist genau gleich für gRpc wie für einen REST-Server.
public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(cfg => { cfg.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; // "Bearer" cfg.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.Authority = Configuration["JwtBearerAuth:Authority"]; options.Audience = Configuration["JwtBearerAuth:Audience"]; options.Events = new JwtBearerEvents() { OnAuthenticationFailed = c => { _logger.LogDebug($"Exception: {c.Exception}"); return Task.CompletedTask; } }; }); services.AddAuthorization(); services.AddControllers(); services.AddGrpc(opt => { opt.EnableDetailedErrors = true; }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) { _logger = loggerFactory.CreateLogger("JwtBearerAuthentication"); if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseHsts(); } app.UseHttpsRedirection(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapGrpcService<DemoServiceRpc>(); endpoints.MapControllers(); }); }
Nun kann man wie üblich die Methoden mit dem Authorize-Attribute schützen.
public class DemoServiceRpc : DemoService.DemoServiceBase { [Authorize(Roles = "User,Admin")] public override Task<NameMessage> ExchangeNames(NameMessage request, ServerCallContext context) { NameMessage result = new NameMessage { Name = "Hello from Server" }; return Task.FromResult(result); } [Authorize(Roles = "Admin")] public override Task<ResultMessage> ChangeSettings(SettingsMessage request, ServerCallContext context) { ResultMessage result = new ResultMessage() { Success = true }; return Task.FromResult(result); } }
Wenn der Client ein JWT-Token für ClientUser gefragt hat und damit auf die Methode ‘ChangeSettings()’ zugreift, dann gibt es folgende Ausnahme:
Fazit
Dieser Blog hat gezeigt, wie Client Credential Flow mit asymmetrischer Verschlüsselung, Rollen und IdentityServer4 verwendet werden kann. Der IdentityServer4 implementiert ebenfalls die kompliziertere OAuth2 Flows, welche ausserhalb den Scope dieser Blog-Serie fallen.
gRPC bietet noch ein paar weitere fortgeschrittenen Funktionen wie Server Side Reflection, um die Schnittstelle des Servers abzufragen (WSDL wird nicht unterstützt) oder Custom Attributes, welche sowieso schon in der C#-Sprache existieren. Bis jetzt ist es den Autor nicht gelungen, einen Client zu implementieren, welcher erfolgreich die gesamte Schnittstelle mit Server Side Reflection abfragen konnte. Der gRPC-Community Support hat bis jetzt keine Lösungen für diese Probleme gebracht.
Damit endet diese Blog-Serie. Meine persönliche Schlussfolgerung ist, dass gRPC WCF nicht vollständig ersetzt (keine Named-Pipes, Server Notifications nur über Streams möglich) und bin enttäuscht, dass Microsoft WCF in .NET Core nicht mehr weiterführt.