accounting:documents

1.2.0

OAuth2.0 Code Flow mit PKCE .NET Beispiel

OAuth2.0 Code Flow mit PKCE .NET Beispiel

Wenn man den Code Flow mit PKCE oder auch .net Core verwenden möchte kann man das Nuget Paket Datev.Clound.Authentication nicht einsetzen weil es aktuell nur den Hybrid Flow und .net Framework unterstützt.

 

Man kann allerdings das Nuget Paket IdentityModel.OidcClient Version 5.2.1 direkt verwenden, hier ein Beispiel für .NET Core 6. Es wird eine Client ID für Code Flow mit der Redirect URI http://localhost benötigt die eine Abonemont für die API accounting-clients hat.

Beispielcode:

using System.Diagnostics;
using System.Net;
using System.Text;
using IdentityModel.Client;
using IdentityModel.OidcClient;
Console.WriteLine("How to use Code Flow with PKCE with the IdP https://login.datev.de/openid / https://login.datev.de/openidsandbox");
// DATEV IdP uses endpoints on other hosts
var datevEndpointBaseAddresses = new List<string>()
     {
         "https://sandbox-api.datev.de",
         "https://api.datev.de",
     };
string myClientId = "6e9bb4a9feff3dcb49b6bafa3e82ea4e";
var options = new OidcClientOptions()
{
    Authority = "https://login.datev.de/openidsandbox", // use https://login.datev.de/openid for production
    ClientId = myClientId,
    ClientSecret = "1477515a8afe4cfadd33fede3ff26dae",
    Scope = "openid datev:accounting:clients", // Scope for Online API accounting:clients
    RedirectUri = $"http://localhost:58455/redirect/{Guid.NewGuid()}/",
    // Redirect URI with random path to avoid collisions on multi user environments like WTS
    Policy = new Policy()
    {
        Discovery = new DiscoveryPolicy()
        {
            AdditionalEndpointBaseAddresses = datevEndpointBaseAddresses
        }
    },
    TokenClientCredentialStyle = ClientCredentialStyle.AuthorizationHeader, // credentials not in body
    RefreshTokenInnerHttpHandler = new HttpClientHandler()
    // usage of own httpclienthandler possible (i. e. for http-proxys)
};
var client = new OidcClient(options);
var state = await client.PrepareLoginAsync();
using (var http = new HttpListener())
{
    http.Prefixes.Add(client.Options.RedirectUri);
    http.Start();
    Console.WriteLine("Listening for Browser Redirect...");
    // Parameter enableWindowsSso for login.datev.de:
    // if you have DATEV-software with Kommunikationsserver installed, you can login with DATEV-Benutzer (DID).
    string startUrlWithSuffix = state.StartUrl + "&enableWindowsSso=true";
    var startInfo = new ProcessStartInfo(startUrlWithSuffix);
    startInfo.UseShellExecute = true;
    Console.WriteLine($"start browser with URL '{startUrlWithSuffix}'");
    Process.Start(startInfo);
    var context = await http.GetContextAsync();
    // handle OPTIONS request (PNA / CORS preflight request)
    if (context.Request.HttpMethod == "OPTIONS")
    {
        Console.WriteLine($"Request with {context.Request.HttpMethod} instead of expected GET");
        var optionsResponse = context.Response;
        var requestHeaders = context.Request.Headers;
        var origin = requestHeaders["Origin"];
        if (origin != null && origin.EndsWith(".datev.de"))
        {
            optionsResponse.AppendHeader("Access-Control-Allow-Headers", "Content-Type, Accept");
            optionsResponse.AppendHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
            optionsResponse.AppendHeader("Access-Control-Max-Age", "86400");
            optionsResponse.AppendHeader("Access-Control-Allow-Origin", origin);
            if (requestHeaders["Access-Control-Request-Private-Network"] != null)
            {
                optionsResponse.AppendHeader("Access-Control-Allow-Private-Network", "true");
            }
        }
        optionsResponse.StatusCode = 204;
        optionsResponse.Close();
        context = await http.GetContextAsync();
    }
    // sends an HTTP response to the browser.
    var response = context.Response;
    string responseString = "<html lang=\"en\"><head><title>DATEV Login finished</title></head><body>Please return to the app.</body></html>";
    var buffer = Encoding.UTF8.GetBytes(responseString);
    response.ContentLength64 = buffer.Length;
    var responseOutput = response.OutputStream;
    await responseOutput.WriteAsync(buffer, 0, buffer.Length);
    responseOutput.Close();
    http.Stop();
    var query = context.Request.Url?.Query;
    Console.WriteLine($"Query: {query}");
    var result = await client.ProcessResponseAsync(query, state);
    if (result.IsError)
    {
        Console.WriteLine($"\n\nError:\n{result.Error}");
    }
    else
    {
        Console.WriteLine();
        Console.WriteLine($"Access token:\n{result.AccessToken}");
        if (!string.IsNullOrWhiteSpace(result.RefreshToken))
        {
            Console.WriteLine($"Refresh token:\n{result.RefreshToken}");
        }
    }
    
    // refreshtokenhandler needed to refresh the token after 15 minutes
    var apiClient = new HttpClient(result.RefreshTokenHandler);
    apiClient.DefaultRequestHeaders.Add("X-DATEV-Client-Id", myClientId); // same ClientId as in options
    var apiResult = await apiClient.GetAsync("https://accounting-clients.api.datev.de/platform-sandbox/v2/clients");
    // use https://accounting-clients.api.datev.de/platform/v2  with IdP/Authority https://login.datev.de/openid for stage production
    apiResult.EnsureSuccessStatusCode();
    var apiContent = await apiResult.Content.ReadAsStringAsync();
    // Reuse the apiClient for all other calls
    Console.WriteLine($"API Response: {apiContent}, http Statuscode: {(int)apiResult.StatusCode} ({apiResult.StatusCode})");
    Console.ReadLine();
}

Zuerst legt man ein .NET Core 6 Console Application Projekt an, und bindet per NuGet das Paket IdentityModel.OidcClient Version 5.2.1 ein. Neuere Versionen können Breaking Changes haben die Änderungen am Sample erfordern. Dann die OidcClientOptions passend zum DATEV IdP anlegen, danach einen neuen OidcClient mit diesen Options erzeugen. Von diesem Client ruft man die Methode PrepareLoginAsync() auf. Man erhält eine StartURL, die man erweitern sollte um den Parameter &enableWindowsSso=true, damit auch die Anmeldung per DATEV Benutzer in Verbindung mit dem Kommunikationsserver unterstützt wird.

Danach startet man einen Browser mit dieser URL. Über die Redirect URI kommt das Ergebnis der Anmeldung im Browser zurück in den Prozess, dazu lauscht man mit dem HttpListener an der Redirect URI. Das Ergebnis des Logins steckt im Query String der URL. Darin enthalten ist unter anderem der Code, der muss noch am Token Endpoint in den endgültigen Access-Token eingetauscht werden. Dies passiert in der Methode client.ProcessResponseAsync().

Danach hat man einen Access- und Refresh-Token, den man manuell verwenden kann. Komfortabler ist es direkt der RefreshTokenHandler zu verwenden der automatisch die Access-Tokens refreshed wenn nötig. Allerdings darf der Refresh Token nur einmal beim DATEV IdP eingelöst werden, wenn der apiClient in verschiedenen Threads genutzt wird muss man einen eigenen RefreshTokenHandler schreiben der den refresh synchronisiert das nur einer der Threads den refresh macht.

Für die API Calls einen neuen HttpClient mit dem RefreshTokenHandler erzeugen und für alle API Calls verwenden.

Nach 11 Stunden werden keine neuen Refresh Token mehr ausgestellt und die API Calls werden mit 401 abgelehnt. In dem Fall muss man von vorne beginnen mit einer neuen Anmeldung:

  • PrepareLoginAsync(),
  • Browser aufrufen und
  • ProcessResponseAsync().