OpenIddict

Prof. Dr. Robin Nunkesser

OpenID Connect

Warum Authentifizierung auslagern?

  • Passwörter nie selbst speichern
  • Standardprotokolle: OAuth 2.0, OpenID Connect (OIDC)
  • OIDC = OAuth 2.0 + Identitätsschicht (wer ist der Nutzer?)

Kernbegriffe

Begriff Bedeutung
Authorization Server stellt Tokens aus
Resource Server schützt API mit Token
Client App, die Token anfordert
Access Token Zugriffsnachweis (kurzlebig)
Authorization Code einmaliger Einlösecode

Authorization Code Flow + PKCE

Client          Browser           Auth-Server        API
  │──── Auth-URL ────▶│                 │              │
  │                   │─── GET /authorize ──▶│         │
  │                   │◀── Login-Formular ───│         │
  │                   │──── POST Login ──────▶│        │
  │◀── code (Redirect)──────────────────────│         │
  │──── POST /token (code + verifier) ──────▶│        │
  │◀── access_token ────────────────────────│         │
  │──────────────────── GET /api/hello ─────────────▶│
  │◀─────────────────── { message }  ────────────────│

PKCE – Proof Key for Code Exchange

  • Schutz gegen Code-Diebstahl (RFC 7636)
  • Client erzeugt zufälligen code_verifier (32 Bytes, Base64Url)
  • code_challenge = SHA256(verifier), Base64Url-kodiert
  • Challenge wird mit Authorization-Request gesendet
  • Verifier wird beim Token-Tausch gesendet — Server prüft
var verifier  = PkceHelper.CreateVerifier();
var challenge = PkceHelper.CreateChallenge(verifier);

OpenIddict

Warum OpenIddict?

  • Kein externer Dienst, kein Account, kein Portal
  • Pure NuGet-Pakete: OpenIddict.AspNetCore, OpenIddict.EntityFrameworkCore
  • Eingebetteter OIDC-Server direkt in ASP.NET Core
  • Ideal für Demos, Tests, Offline-Entwicklung

Projektstruktur

Authentication.Core/    ← PkceHelper (PKCE-Logik)
Authentication.Server/  ← OpenIddict OIDC-Server (Port 5001)
Authentication.Client/  ← Console-Client (PKCE-Flow)
Authentication.Tests/   ← xUnit (RFC 7636 Testvektor)
  • Core und Tests ohne Server-Abhängigkeit testbar.

PkceHelper

public static class PkceHelper
{
    public static string CreateVerifier()
    {
        var bytes = RandomNumberGenerator.GetBytes(32);
        return Base64UrlEncode(bytes);
    }

    public static string CreateChallenge(string verifier)
    {
        var bytes = SHA256.HashData(Encoding.ASCII.GetBytes(verifier));
        return Base64UrlEncode(bytes);
    }
}

Server: OpenIddict konfigurieren

builder.Services.AddOpenIddict()
    .AddCore(o => o.UseEntityFrameworkCore()
                   .UseDbContext<AppDbContext>())
    .AddServer(o =>
    {
        o.SetAuthorizationEndpointUris("/connect/authorize")
         .SetTokenEndpointUris("/connect/token");

        o.AllowAuthorizationCodeFlow()
         .RequireProofKeyForCodeExchange();

        o.AddDevelopmentEncryptionCertificate()
         .AddDevelopmentSigningCertificate();

        o.UseAspNetCore()
         .EnableAuthorizationEndpointPassthrough()
         .EnableTokenEndpointPassthrough()
         .DisableTransportSecurityRequirement();
    });

Server: Demo-Client registrieren

await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
    ClientId   = "demo-app",
    ClientType = ClientTypes.Public,   // kein Client Secret
    RedirectUris = { new Uri("http://localhost:5002/callback") },
    Permissions =
    {
        Permissions.Endpoints.Authorization,
        Permissions.Endpoints.Token,
        Permissions.GrantTypes.AuthorizationCode,
        Permissions.ResponseTypes.Code,
        Permissions.Scopes.Profile,
    }
});

Server: Authorization-Endpunkt

app.MapGet("/connect/authorize", async (HttpContext ctx) =>
{
    var result = await ctx.AuthenticateAsync(
        CookieAuthenticationDefaults.AuthenticationScheme);
    if (!result.Succeeded)
        return Results.Redirect($"/account/login?returnUrl=…");

    var request  = ctx.GetOpenIddictServerRequest()!;
    var identity = new ClaimsIdentity(
        [new Claim(Claims.Subject, result.Principal!.Identity!.Name!),
         new Claim(Claims.Name,    result.Principal!.Identity!.Name!)],
        OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
    identity.SetScopes(request.GetScopes());

    return Results.SignIn(new ClaimsPrincipal(identity),
        authenticationScheme:
            OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
});

Client: PKCE-Flow

var verifier  = PkceHelper.CreateVerifier();
var challenge = PkceHelper.CreateChallenge(verifier);
var state     = Base64UrlRandom();

// Browser öffnen
Process.Start(new ProcessStartInfo(authUrl) { UseShellExecute = true });

// Callback abhören
using var listener = new HttpListener();
listener.Prefixes.Add("http://localhost:5002/");
listener.Start();
var ctx = await listener.GetContextAsync();
var code = HttpUtility.ParseQueryString(ctx.Request.Url!.Query)["code"]!;

// Token tauschen
var tokenResponse = await http.PostAsync("/connect/token",
    new FormUrlEncodedContent(new Dictionary<string, string> {
        ["grant_type"] = "authorization_code", ["code"] = code,
        ["redirect_uri"] = RedirectUri, ["client_id"] = ClientId,
        ["code_verifier"] = verifier }));

Demo starten

# Terminal 1 – OIDC-Server starten
dotnet run --project Authentication.Server

# Terminal 2 – Client-Login ausführen
dotnet run --project Authentication.Client
  • Browser öffnet sich mit Login-Formular (alice / alice)
  • Console zeigt Authorization Code, Access Token, API-Antwort

Tests

dotnet test
  • 3 Tests (xUnit)
  • RFC 7636 Testvektor:
    verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
    → challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

Beispielprojekt

GitHub OAuth in MAUI

Warum GitHub OAuth?

  • Alle Studierenden haben einen GitHub-Account
  • App-Registrierung in ~2 Minuten
  • Kein eigener Server nötig
  • Praxisrelevant: Social Login auf Basis OAuth 2.0

Architektur (Ports & Adapters)

Authentication.Core          ← Domain
  GitHubUser (record)
  IGitHubAuthService          Port
  LoginViewModel (MVVM)

Authentication.Infrastructure ← Adapter
  GitHubAuthService           OAuth + OidcClient

Authentication              ← MAUI-App
  MauiBrowser                 WebAuthenticator
  MauiProgram                 DI-Setup
  MainPage.xaml               UI

GitHub OAuth-Flow in MAUI

@startuml
actor User
participant "MAUI App" as App
participant "System Browser" as Browser
participant "GitHub" as GitHub

User -> App: Login-Button
App -> Browser: WebAuthenticator.AuthenticateAsync()\n(Authorization URL + PKCE)
Browser -> GitHub: GET /login/oauth/authorize
GitHub -> Browser: Login-Seite
User -> Browser: Anmeldung bestätigen
Browser -> App: mauigithubauth://callback?code=…
App -> GitHub: POST /login/oauth/access_token
GitHub -> App: access_token
App -> GitHub: GET /user (Bearer Token)
GitHub -> App: GitHubUser (Login, Name, Avatar)
App -> User: Profil anzeigen
@enduml

WebAuthenticator

  • MAUI Essentials: öffnet System-Browser
  • Fängt Redirect auf Custom URL Scheme ab
  • iOS/macOS: CFBundleURLTypes in Info.plist
  • Android: <intent-filter> in AndroidManifest.xml
  • URL Scheme: mauigithubauth://callback

IBrowser (OidcClient-Integration)

public class MauiBrowser : IBrowser
{
    public async Task<BrowserResult> InvokeAsync(
        BrowserOptions options, CancellationToken ct)
    {
        var result = await WebAuthenticator.Default
            .AuthenticateAsync(
                new Uri(options.StartUrl),
                new Uri(options.EndUrl));

        var url = options.EndUrl
            + "?" + string.Join("&",
                result.Properties
                      .Select(kvp => $"{kvp.Key}={kvp.Value}"));

        return new BrowserResult
        {
            Response     = url,
            ResultType   = BrowserResultType.Success
        };
    }
}

GitHub als OAuth-Provider konfigurieren

var options = new OidcClientOptions
{
    ClientId     = "YOUR_CLIENT_ID",
    ClientSecret = "YOUR_CLIENT_SECRET",
    Scope        = "read:user",
    RedirectUri  = "mauigithubauth://callback",
    Browser      = _browser,
    ProviderInformation = new ProviderInformation
    {
        IssuerName        = "https://github.com",
        AuthorizeEndpoint =
            "https://github.com/login/oauth/authorize",
        TokenEndpoint     =
            "https://github.com/login/oauth/access_token",
    }
};
var client = new OidcClient(options);
var loginResult = await client.LoginAsync();

Benutzerinformationen abrufen

var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);
http.DefaultRequestHeaders.UserAgent
    .ParseAdd("csharp-maui-github-auth");

var response = await http.GetStringAsync(
    "https://api.github.com/user");

var dto = JsonSerializer.Deserialize<GitHubUserDto>(response);
return new GitHubUser(dto.Login, dto.Name, dto.AvatarUrl);

Ports & Adapters: DI-Setup

builder.Services
    .AddSingleton<IBrowser, MauiBrowser>()
    .AddSingleton<IGitHubAuthService, GitHubAuthService>()
    .AddSingleton<LoginViewModel>()
    .AddSingleton<MainPage>();
  • IBrowser → MAUI-App-Schicht (WebAuthenticator)
  • IGitHubAuthService → Infrastructure (OidcClient)
  • LoginViewModel → Core (keine MAUI-Abhängigkeit)

Tests ohne MAUI (xUnit)

[Fact]
public async Task LoginAsync_SetsUserProperties_OnSuccess()
{
    var service = new FakeGitHubAuthService("token",
        new GitHubUser("robinnunkesser", "Robin", "…"));
    var vm = new LoginViewModel(service);

    await vm.LoginCommand.ExecuteAsync(null);

    Assert.True(vm.IsLoggedIn);
    Assert.Equal("robinnunkesser", vm.Login);
}
  • Core + Tests: dotnet test ohne MAUI-Toolchain
  • MAUI-App: nur auf macOS/Simulator/Gerät buildbar

App einrichten

  1. GitHub → Settings → Developer settings → OAuth Apps → New
  2. Homepage URL: https://github.com/RobinNunkesser
  3. Callback URL: mauigithubauth://callback
  4. Client ID + Secret in GitHubAuthService.cs eintragen
  5. App starten → Login-Button → System-Browser öffnet sich

Beispielprojekt MAUI

  • csharp-maui-github-auth

  • NuGet: IdentityModel.OidcClient 6.0.0, CommunityToolkit.Mvvm 8.4.2

  • Kein Account, kein externer Dienst, vollständig offline lauffähig