hr:files

2.0.0

OAuth2.0 Code Flow with PKCE .NET Sample

OAuth2.0 Code Flow with PKCE .NET Sample

If you want to use the Code Flow with PKCE or .net Core you cannot use the Nuget package Datev.Clound.Authentication because it currently only supports the Hybrid Flow and .net Framework.

 

However, you can use the Nuget package IdentityModel.OidcClient 5.2.1 directly, here is an example for .NET Core 6. You need a client ID for Code Flow with the redirect URI http://localhost and a subscription for the API accounting-clients.

Sample code:

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();
}

First create a .NET Core 6 console application project and install the NuGet package IdentityModel.OidcClient version 5.2.1. Newer version may have breaking changes that require changes to the sample.

Then create the OidcClientOptions for the DATEV IdP, and create a new OidcClient with these options. Call the method PrepareLoginAsync(). You get a StartURL, which should be extended by the parameter &enableWindowsSso=true to support login via "DATEV Benutzer" and "Kommunikationsserver".

Then start a browser with this URL. Listen with a HttpListener on the Redirect URI. The result of the login is in the query string of the Redirect URL, then call client.ProcessResponseAsync() to exchange the code into the access token und refresh token.

You can manually add the Access Token to the http header, but it is more convenient to use the RefreshTokenHandler. The RefreshTokenHandler refreshes the Access Token automatically if necessary. But the refresh token may only be redeemed once at the DATEV IdP, if the apiClient is used in different threads you have to write your own RefreshTokenHandler which synchronizes the refresh so that only one of the threads does the refresh.

After 11 hours, no new refresh tokens are issued and API calls are rejected with 401. In this case you have to start over with a new login:

  • PrepareLoginAsync(),
  • start browser and
  • ProcessResponseAsync().