Relying party documentation

ASP.NET Core integration

Cookie authentication plus OpenID Connect against broker OIDC discovery. Each step is copy-paste ready — use WSO2 URLs, not the eID API directly, for production RPs.

  • Configure appsettings + user secrets (broker client, scopes incl. eid)
  • Register cookie + OpenID Connect in Program.cs (PKCE, UserInfo endpoint)
  • Map and persist sub, eid, citizen_status, rp_token
  • Test /login → broker → eID → signin-oidc callback
  • Steps 12–16: Eid:ApiBaseUrl, catalog service, PortalSwitcher view component, /login/sso + prompt=none
  • curl eID /api/public/government-services?excludeClientId={ClientId} — tiles after admin approval

Generic copy-paste steps using placeholder client credentials. After onboarding, open your application in the RP portal for guides pre-filled with your client ID and redirect URIs.

Step 1

Architecture overview

Use ASP.NET Core OpenID Connect middleware against the SXM Identity Broker. The broker federates to the national eID UI — configure discovery, token, and UserInfo against WSO2, not the eID API (port 5026) in production.

text
Browser → /login (Challenge OIDC + PKCE)
       → Broker authorize (https://localhost:9443/oauth2/authorize)
       → SXM eID login + MFA
       → GET /signin-oidc (middleware callback)
       → Token + UserInfo (https://localhost:9443/oauth2/userinfo)
       → Cookie sign-in with merged claims (sub, profile, eid bundle)
       → Optional: persist ApplicationUser (OidcSub, Eid, …)
Step 2

Install NuGet packages

Cookie session plus OpenID Connect against broker metadata.

shell
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
dotnet add package Microsoft.AspNetCore.Authentication.Cookies
Step 3

Configure appsettings.Development.json

Include scope eid for citizen_status, rp_token, and eid (card number). Keep secrets in user secrets or a vault in production.

json
{
  "Authentication": {
    "Schemes": {
      "OpenIdConnect": {
        "ClientId": "YOUR_CLIENT_ID",
        "ClientSecret": "YOUR_CLIENT_SECRET",
        "CallbackPath": "/signin-oidc",
        "SignedOutCallbackPath": "/signout-callback-oidc",
        "ResponseType": "code",
        "SaveTokens": true,
        "GetClaimsFromUserInfoEndpoint": true,
        "MetadataAddress": "https://localhost:9443/oauth2/oidcdiscovery/.well-known/openid-configuration",
        "Scope": ["openid", "profile", "email", "eid"]
      }
    }
  },
  "Wso2": {
    "AuthorizeEndpoint": "https://localhost:9443/oauth2/authorize",
    "TokenEndpoint": "https://localhost:9443/oauth2/token",
    "UserInfoEndpoint": "https://localhost:9443/oauth2/userinfo",
    "PostLogoutRedirectUri": "https://my-portal.gov.sx/"
  }
}
Step 4

Store the client secret locally

Do not commit ClientSecret. Use user secrets in development.

shell
cd src/YourRpWebApp
dotnet user-secrets init
dotnet user-secrets set "Authentication:Schemes:OpenIdConnect:ClientSecret" "YOUR_CLIENT_SECRET"
Step 5

Register authentication in Program.cs

PKCE is required. GetClaimsFromUserInfoEndpoint merges broker UserInfo with the id_token. Use prompt=login for interactive login — never prompt=consent from the RP.

csharp
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie()
    .AddOpenIdConnect(options =>
    {
        var oidc = builder.Configuration.GetSection("Authentication:Schemes:OpenIdConnect");

        options.ClientId = oidc["ClientId"]!;
        options.ClientSecret = oidc["ClientSecret"]!;
        options.ResponseType = OpenIdConnectResponseType.Code;
        options.SaveTokens = true;
        options.GetClaimsFromUserInfoEndpoint = true;
        options.CallbackPath = oidc["CallbackPath"] ?? "/signin-oidc";
        options.SignedOutCallbackPath = oidc["SignedOutCallbackPath"] ?? "/signout-callback-oidc";
        options.MetadataAddress = oidc["MetadataAddress"] ?? "https://localhost:9443/oauth2/oidcdiscovery/.well-known/openid-configuration";

        options.Scope.Clear();
        foreach (var scope in oidc.GetSection("Scope").Get<string[]>() ?? ["openid", "profile", "email", "eid"])
        {
            options.Scope.Add(scope);
        }

        options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
        options.PKCE = true;

        options.Events = new OpenIdConnectEvents
        {
            OnRedirectToIdentityProvider = context =>
            {
                context.ProtocolMessage.Prompt = "login";
                return Task.CompletedTask;
            },
            OnUserInformationReceived = context =>
            {
                NormalizeWso2CustomClaims(context.Principal);
                return Task.CompletedTask;
            },
        };
    });

builder.Services.AddAuthorization();
builder.Services.AddRazorPages();

var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/login", () => Results.Challenge(new AuthenticationProperties { RedirectUri = "/" }))
   .AllowAnonymous();

app.MapGet("/logout", async (HttpContext http) =>
{
    await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    await http.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties
    {
        RedirectUri = "/",
    });
}).RequireAuthorization();

app.MapRazorPages().RequireAuthorization();
app.Run();
Step 6

Normalize broker custom claims (optional helper)

WSO2 may return custom attributes as full URIs. Map them to short names eid, citizen_status, rp_token so controllers and EF use consistent keys.

csharp
using System.Security.Claims;

static void NormalizeWso2CustomClaims(ClaimsPrincipal? principal)
{
    if (principal?.Identity is not ClaimsIdentity identity)
    {
        return;
    }

    var map = new Dictionary<string, string>(StringComparer.Ordinal)
    {
        ["http://wso2.org/claims/custom/eid"] = "eid",
        ["http://wso2.org/claims/custom/citizen_status"] = "citizen_status",
        ["http://wso2.org/claims/custom/rp_token"] = "rp_token",
    };

    foreach (var (uri, name) in map)
    {
        var existing = identity.FindFirst(name);
        var fromUri = identity.FindFirst(uri);
        if (existing is null && fromUri is not null)
        {
            identity.AddClaim(new Claim(name, fromUri.Value));
        }
    }
}
Step 7

Match redirect URI with WSO2 registration

Default middleware callback is {your-app-origin}/signin-oidc. Registered redirect in WSO2 must match exactly.

text
# Registered in WSO2 for RP-EXAMPLE-0001 (example):
https://my-portal.gov.sx/auth/callback

# Default OIDC middleware callback (if app runs at https://localhost:5001):
https://localhost:5001/signin-oidc

# Post-logout redirect (configure in WSO2 + SignedOutCallbackPath):
https://my-portal.gov.sx/
Step 8

Read identity claims in controllers

Approved claims: sub, name, given_name, family_name, email, birthdate, citizen_status, rp_token, eid. Broker username is not the eID card number — use claim eid when present.

csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[Authorize]
public class DashboardController : Controller
{
    public IActionResult Index()
    {
        var sub = User.FindFirstValue("sub");
        var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
        var name = User.FindFirstValue(ClaimTypes.Name) ?? User.FindFirstValue("name");
        var eid = User.FindFirstValue("eid");
        var citizenStatus = User.FindFirstValue("citizen_status");
        var rpToken = User.FindFirstValue("rp_token");

        ViewData["Subject"] = sub;
        ViewData["Email"] = email;
        ViewData["Name"] = name;
        ViewData["Eid"] = eid;
        ViewData["CitizenStatus"] = citizenStatus;
        ViewData["RpToken"] = rpToken;

        return View();
    }
}
Step 9

Persist user after sign-in (EF Core excerpt)

Sync broker sub and eid-scope fields to your database on token validation. Adjust entity names to your project.

csharp
options.Events = new OpenIdConnectEvents
{
    OnTokenValidated = async context =>
    {
        var sub = context.Principal?.FindFirstValue("sub");
        if (string.IsNullOrEmpty(sub))
        {
            return;
        }

        var db = context.HttpContext.RequestServices.GetRequiredService<AppDbContext>();
        var user = await db.PortalUsers.FirstOrDefaultAsync(u => u.OidcSub == sub);
        if (user is null)
        {
            user = new PortalUser { OidcSub = sub };
            db.PortalUsers.Add(user);
        }

        user.Email = context.Principal?.FindFirstValue("email");
        user.DisplayName = context.Principal?.FindFirstValue("name");
        user.Eid = context.Principal?.FindFirstValue("eid");
        user.CitizenStatus = context.Principal?.FindFirstValue("citizen_status");
        user.RpToken = context.Principal?.FindFirstValue("rp_token");

        await db.SaveChangesAsync();
    },
};
Step 10

Manual UserInfo (debugging)

When SaveTokens is true, read the access token and call broker UserInfo. Do not call eID /connect/userinfo with the broker token.

csharp
// After sign-in (e.g. in a dev-only endpoint):
var accessToken = await HttpContext.GetTokenAsync("access_token");
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
    new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
var json = await client.GetStringAsync("https://localhost:9443/oauth2/userinfo");

// Or from shell with token from SaveTokens / logs:
curl -sS -H "Authorization: Bearer {broker_access_token}" \
  https://localhost:9443/oauth2/userinfo
Step 11

Test end-to-end

Confirm eID login UI (not WSO2 local login). If eid / rp_token are missing, see /developers/sso/claims troubleshooting.

shell
dotnet run --project src/YourRpWebApp

# Browse to https://localhost:5001/login
# Flow: App → broker → eID UI → /signin-oidc → signed-in user

# Optional UserInfo check (broker access token):
curl -sS -H "Authorization: Bearer {broker_access_token}" \
  https://localhost:9443/oauth2/userinfo

# Troubleshooting:
# - redirect URI mismatch → register exact callback in WSO2
# - invalid_client → client ID / secret
# - only profile claims → scope must include eid; broker consent / claim mapping
# - do not POST broker access_token to eID http://localhost:5026/connect/userinfo
Step 12

Portal switcher — appsettings (Eid + WSO2 client id)

Add an Eid section. Wso2:ClientId is sent as excludeClientId when fetching the public catalog.

json
{
  "Eid": {
    "ApiBaseUrl": "http://localhost:5026",
    "FrontendUrl": "http://localhost:3000",
    "CitizenPortalPath": "/citizen/profile"
  },
  "Wso2": {
    "ClientId": "YOUR_WSO2_CLIENT_ID"
  }
}
Step 13

Portal switcher — EidGovernmentPortalCatalog.cs

Register with AddHttpClient + IMemoryCache. Cache ~10 minutes like the Laravel guide.

csharp
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;

public sealed class EidGovernmentPortalCatalog(
    IHttpClientFactory httpClientFactory,
    IConfiguration configuration,
    IMemoryCache cache)
{
    public async Task<IReadOnlyList<PortalTile>> GetLaunchablePortalsAsync(CancellationToken ct = default)
    {
        var baseUrl = configuration["Eid:ApiBaseUrl"]?.TrimEnd('/');
        if (string.IsNullOrEmpty(baseUrl))
        {
            return Array.Empty<PortalTile>();
        }

        var exclude = Uri.EscapeDataString(configuration["Wso2:ClientId"] ?? "");
        var cacheKey = $"eid-catalog:{baseUrl}:{exclude}";

        return await cache.GetOrCreateAsync(cacheKey, async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            var client = httpClientFactory.CreateClient();
            var url = $"{baseUrl}/api/public/government-services?excludeClientId={exclude}";
            using var response = await client.GetAsync(url, ct);
            response.EnsureSuccessStatusCode();

            var payload = await response.Content.ReadFromJsonAsync<PublicCatalogResponse>(cancellationToken: ct);
            return payload?.Portals?
                .Where(p => !string.IsNullOrWhiteSpace(p.SsoLoginUrl))
                .Select(p => new PortalTile(
                    p.Label ?? "Government service",
                    p.SsoLoginUrl!,
                    p.ShortLabel ?? "GOV",
                    p.Accent ?? "#334155"))
                .ToList() ?? [];
        }) ?? [];
    }

    private sealed class PublicCatalogResponse
    {
        [JsonPropertyName("portals")]
        public List<PortalDto>? Portals { get; init; }
    }

    private sealed class PortalDto
    {
        [JsonPropertyName("clientId")]
        public string? ClientId { get; init; }

        [JsonPropertyName("label")]
        public string? Label { get; init; }

        [JsonPropertyName("shortLabel")]
        public string? ShortLabel { get; init; }

        [JsonPropertyName("ssoLoginUrl")]
        public string? SsoLoginUrl { get; init; }

        [JsonPropertyName("accent")]
        public string? Accent { get; init; }
    }
}

public readonly record struct PortalTile(string Label, string SsoLoginUrl, string Short, string Accent);
Step 14

Portal switcher — Register services + /login/sso

Add silent SSO route. Set prompt=none only for /login/sso; keep prompt=login on /login.

csharp
// Program.cs
builder.Services.AddHttpClient();
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<EidGovernmentPortalCatalog>();

// After app = builder.Build() — OIDC events excerpt:
options.Events = new OpenIdConnectEvents
{
    OnRedirectToIdentityProvider = context =>
    {
        if (context.Request.Path.StartsWithSegments("/login/sso"))
        {
            context.ProtocolMessage.Prompt = "none";
        }
        else
        {
            context.ProtocolMessage.Prompt = "login";
        }
        return Task.CompletedTask;
    },
    OnRemoteFailure = context =>
    {
        if (context.Request.Path.StartsWithSegments("/login/sso")
            && context.Failure?.Message.Contains("login_required", StringComparison.OrdinalIgnoreCase) == true)
        {
            context.Response.Redirect("/login");
            context.HandleResponse();
        }
        return Task.CompletedTask;
    },
};

app.MapGet("/login", () => Results.Challenge(new AuthenticationProperties { RedirectUri = "/" }))
   .AllowAnonymous();

app.MapGet("/login/sso", () => Results.Challenge(new AuthenticationProperties { RedirectUri = "/" }))
   .AllowAnonymous();
Step 15

Portal switcher — Razor header (View Component)

Inject the catalog in a View Component and render links to each ssoLoginUrl plus My eID.

csharp
// ViewComponents/PortalSwitcherViewComponent.cs
public class PortalSwitcherViewComponent(EidGovernmentPortalCatalog catalog, IConfiguration config) : ViewComponent
{
    public async Task<IViewComponentResult> InvokeAsync()
    {
        var portals = await catalog.GetLaunchablePortalsAsync(HttpContext.RequestAborted);
        var eidBase = config["Eid:FrontendUrl"]?.TrimEnd('/') ?? "";
        var path = config["Eid:CitizenPortalPath"] ?? "/citizen/profile";
        ViewBag.EidCitizenUrl = eidBase != "" ? eidBase + path : null;
        return View(portals);
    }
}

// Views/Shared/Components/PortalSwitcher/Default.cshtml
@model IReadOnlyList<PortalTile>
<div class="gov-launcher">
  @if (ViewBag.EidCitizenUrl is string eidUrl)
  {
    <a href="@eidUrl">My eID</a>
  }
  @foreach (var portal in Model)
  {
    <a href="@portal.SsoLoginUrl" title="@portal.Label">@portal.Short</a>
  }
</div>

// _Layout.cshtml (authenticated)
@await Component.InvokeAsync("PortalSwitcher")
Step 16

Portal switcher — verify catalog API

Run eID API locally on 5026. Replace YOUR_WSO2_CLIENT_ID with your broker SP client_id.

shell
curl -s "http://localhost:5026/api/public/government-services?excludeClientId=YOUR_WSO2_CLIENT_ID" | jq .

# Each portal.ssoLoginUrl should be https://your-peer.gov/login/sso
# After admin approves your RP, your app appears automatically (no manual tile config).