← Duende SkillsCONTENT HISTORY

Update to Duende Skills

Snapshot Sep 30, 2026 · 23:14 UTC · version 0.3.0

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "name": "identityserver-ui-flows",
  "description": "Guide for building login, logout, consent, error, and federation gateway UI pages in Duende IdentityServer, including IIdentityServerInteractionService usage, external provider integration, and Home Realm Discovery strategies.",
  "included_files": [],
  "skill_md_contents": "---\nname: identityserver-ui-flows\ndescription: \"Guide for building login, logout, consent, error, and federation gateway UI pages in Duende IdentityServer, including IIdentityServerInteractionService usage, external provider integration, and Home Realm Discovery strategies.\"\ninvocable: false\n---\n\n# IdentityServer UI Flows: Login, Logout, Consent, and Federation\n\n## When to Use This Skill\n\n- Building or customizing the login page (local credentials, MFA, passwordless)\n- Integrating external identity providers (Google, Azure AD, SAML, etc.)\n- Implementing the consent page for third-party client authorization\n- Building the logout flow with session cleanup and client notifications\n- Implementing a federation gateway with Home Realm Discovery (HRD)\n- Handling and displaying error pages for protocol errors\n- Using `IIdentityServerInteractionService` to interact with the protocol engine\n- Redirecting users back to clients after login/logout\n\nDocs: https://docs.duendesoftware.com/identityserver/ui\n\n## Architecture Overview\n\nIdentityServer separates the protocol engine from the user interface. The engine handles OAuth/OIDC endpoints and redirects to your UI pages as needed. Your UI code handles all user interaction and then communicates results back to the engine.\n\n```\nBrowser → IdentityServer Middleware → UI Pages (Login, Consent, Logout, Error)\n                                          ↕\n                                   IIdentityServerInteractionService\n                                          ↕\n                                   IdentityServer Protocol Engine\n```\n\n### Required Pages\n\n| Page    | Purpose                               | Default URL                               |\n| ------- | ------------------------------------- | ----------------------------------------- |\n| Login   | Establish authentication session      | Inferred from cookie handler `LoginPath`  |\n| Logout  | Terminate session, notify clients     | Set via `opt.UserInteraction.LogoutUrl`   |\n| Consent | Grant/deny client access to resources | `/consent`                                |\n| Error   | Display protocol error information    | `/home/error`                             |\n\n## Login Page\n\n### Configuring the Login URL\n\n```csharp\n// Program.cs — explicit configuration\nbuilder.Services.AddIdentityServer(opt => {\n    opt.UserInteraction.LoginUrl = \"/path/to/login\";\n});\n```\n\nIf not set, IdentityServer infers the URL from the cookie handler's `LoginPath`:\n\n```csharp\n// Program.cs — with ASP.NET Identity\nbuilder.Services.AddIdentityServer()\n    .AddAspNetIdentity<ApplicationUser>();\n\nbuilder.Services.ConfigureApplicationCookie(options =>\n{\n    options.LoginPath = \"/path/to/login/for/aspnet_identity\";\n});\n```\n\n### Authorization Context\n\nWhen IdentityServer redirects to the login page, it passes a `returnUrl` query parameter. Use `IIdentityServerInteractionService.GetAuthorizationContextAsync` to extract the original authorization request parameters:\n\n```csharp\npublic class LoginModel : PageModel\n{\n    private readonly IIdentityServerInteractionService _interaction;\n\n    public LoginModel(IIdentityServerInteractionService interaction)\n    {\n        _interaction = interaction;\n    }\n\n    public async Task<IActionResult> OnGet(string returnUrl)\n    {\n        var context = await _interaction.GetAuthorizationContextAsync(returnUrl);\n\n        // context contains:\n        // - Client (the requesting client)\n        // - IdP (requested identity provider hint)\n        // - AcrValues (requested authentication context)\n        // - Tenant (requested tenant)\n        // - LoginHint (suggested username)\n        // - Parameters (raw protocol parameters)\n\n        // Use context for branding, HRD, MFA decisions, etc.\n    }\n}\n```\n\n**Important**: Do not parse the `returnUrl` yourself. Always use the interaction service.\n\n### Establishing the Authentication Session\n\nAfter validating credentials, create the authentication session:\n\n```csharp\nvar user = new IdentityServerUser(\"unique_id_for_your_user\")\n{\n    DisplayName = \"Bob Smith\"\n};\n\nawait HttpContext.SignInAsync(user);\n\n// Redirect back to the authorization endpoint\nreturn Redirect(returnUrl);\n```\n\nOr with explicit claims:\n\n```csharp\nvar claims = new Claim[] {\n    new Claim(\"sub\", \"unique_id_for_your_user\"),\n    new Claim(\"name\", \"Bob Smith\"),\n    new Claim(\"amr\", \"pwd\"),\n    new Claim(\"idp\", \"local\")\n};\nvar identity = new ClaimsIdentity(claims, \"pwd\");\nvar principal = new ClaimsPrincipal(identity);\n\nawait HttpContext.SignInAsync(principal);\n```\n\n### Well-Known Session Claims\n\n| Claim       | Purpose                                                                   | Default                    |\n| ----------- | ------------------------------------------------------------------------- | -------------------------- |\n| `sub`       | **Required.** Unique user identifier. Must never change or be reassigned. | None — you must provide it |\n| `name`      | Display name of the user                                                  | None                       |\n| `amr`       | Authentication method reference                                           | `pwd`                      |\n| `auth_time` | Time user entered credentials (epoch)                                     | Current time               |\n| `idp`       | Identity provider scheme name                                             | `local`                    |\n| `tenant`    | Tenant identifier                                                         | None                       |\n\n### Protecting Against Open Redirects\n\nAlways validate the `returnUrl` before redirecting:\n\n```csharp\n// Option 1: Use ASP.NET Core helper\nif (Url.IsLocalUrl(returnUrl))\n{\n    return Redirect(returnUrl);\n}\n\n// Option 2: Use IdentityServer interaction service\nif (await _interaction.IsValidReturnUrl(returnUrl))\n{\n    return Redirect(returnUrl);\n}\n```\n\n### Completing Login with CompleteLoginAsync\n\nAfter establishing the authentication session, redirect the user back to the `returnUrl`. This causes the browser to re-issue the original authorize request, allowing IdentityServer to complete the protocol workflow.\n\n## External Login (Federation)\n\n### Registering External Providers\n\n```csharp\n// Program.cs\nbuilder.Services.AddIdentityServer();\n\nbuilder.Services.AddAuthentication()\n    .AddOpenIdConnect(\"AAD\", \"Employee Login\", options =>\n    {\n        options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;\n        // configure authority, client ID, etc.\n    });\n```\n\n### Triggering External Authentication\n\n```csharp\nvar callbackUrl = Url.Action(\"MyCallback\");\n\nvar props = new AuthenticationProperties\n{\n    RedirectUri = callbackUrl,\n    Items =\n    {\n        { \"scheme\", \"AAD\" },\n        { \"returnUrl\", returnUrl }\n    }\n};\n\nreturn Challenge(\"AAD\", props);\n```\n\n### Handling the Callback\n\n```csharp\n// 1. Read external identity from temporary cookie\nvar result = await HttpContext.AuthenticateAsync(\n    IdentityServerConstants.ExternalCookieAuthenticationScheme);\n\nif (result?.Succeeded != true)\n    throw new Exception(\"External authentication error\");\n\nvar externalUser = result.Principal;\nvar userId = externalUser.FindFirst(\"sub\").Value;\nvar scheme = result.Properties.Items[\"scheme\"];\nvar returnUrl = result.Properties.Items[\"returnUrl\"] ?? \"~/\";\n\n// 2. Find or provision local user\nvar user = FindUserFromExternalProvider(scheme, userId);\n\n// 3. Establish session\nawait HttpContext.SignInAsync(new IdentityServerUser(user.SubjectId)\n{\n    DisplayName = user.DisplayName,\n    IdentityProvider = scheme\n});\n\n// 4. Clean up external cookie\nawait HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);\n\n// 5. Return to protocol processing\nreturn Redirect(returnUrl);\n```\n\n### SignInScheme and SignOutScheme\n\n| Scenario                 | SignInScheme                                                 | SignOutScheme                           |\n| ------------------------ | ------------------------------------------------------------ | --------------------------------------- |\n| Without ASP.NET Identity | `IdentityServerConstants.ExternalCookieAuthenticationScheme` | `IdentityServerConstants.SignoutScheme` |\n| With ASP.NET Identity    | `IdentityServerConstants.ExternalCookieAuthenticationScheme` | `IdentityConstants.ApplicationScheme`   |\n\n### State and URL Length\n\nIf external provider state makes the URL too long (>2000 chars), use the IdentityServer-provided `IDistributedCache`-backed data format:\n\n```csharp\n// Program.cs — all OIDC handlers use server-side state\nbuilder.Services.AddOidcStateDataFormatterCache();\n\n// Or specific schemes only\nbuilder.Services.AddOidcStateDataFormatterCache(\"aad\", \"demoidsrv\");\n```\n\n## Logout Page\n\n### Configuring the Logout URL\n\n```csharp\n// Program.cs\nbuilder.Services.AddIdentityServer(opt => {\n    opt.UserInteraction.LogoutUrl = \"/path/to/logout\";\n});\n```\n\n### Logout Steps\n\n1. **End the IdentityServer session** — remove the authentication cookie\n2. **Sign out of external provider** — if an external login was used\n3. **Notify client applications** — via front-channel, back-channel, or JS-based notifications\n4. **Redirect back to client** — if the logout is client-initiated\n\n### Client Notification Mechanisms\n\n| Mechanism     | How It Works                                                         | Client Setting                       |\n| ------------- | -------------------------------------------------------------------- | ------------------------------------ |\n| Front-channel | Render `<iframe>` on logged-out page pointing to client's logout URI | `FrontChannelLogoutUri`              |\n| Back-channel  | Server-to-server HTTP call with a logout JWT (`typ: logout+jwt`)     | `BackChannelLogoutUri`               |\n| JS-based      | Client monitors `check_session_iframe`                               | Built into spec-compliant JS clients |\n\n**Recommendation**: Use back-channel notifications for cross-site architectures. Front-channel and JS-based notifications rely on cookies in iframes, which may not work reliably across different sites.\n\n### Getting Logout Context\n\n```csharp\nvar context = await _interaction.GetLogoutContextAsync(logoutId);\n\n// context.SignOutIFrameUrl — render in <iframe> for front-channel logout\n// context.PostLogoutRedirectUri — where to send the user after logout\n```\n\n### Back-Channel Logout\n\nBack-channel logout happens automatically when you call `HttpContext.SignOutAsync()` — IdentityServer uses `IBackChannelLogoutService` to notify all clients that have `BackChannelLogoutUri` configured.\n\nFor .NET clients: use the BFF framework which has built-in back-channel logout support, or see the IdentityServer samples.\n\n## Consent Page\n\n### When Consent Is Required\n\nConsent applies **only to user-based (interactive) authorization requests**. Client-credentials (M2M) flows never prompt for consent — there `Client.AllowedScopes` alone governs access.\n\nConsent is controlled per client via `RequireConsent` (default: `false`). Set `RequireConsent = false` for first-party clients to suppress the scope prompt; set `true` for third-party clients. When enabled, IdentityServer redirects to the consent page before completing authorization.\n\nThe `offline_access` scope always triggers consent when the client has consent enabled.\n\n### Required vs. Optional Scopes\n\n`IdentityResource` and `ApiScope` expose a `Required` bool:\n\n- If the consent response **omits a `Required` scope**, IdentityServer returns `access_denied` and the request fails.\n- **Optional** scopes can be declined and the flow still succeeds — the issued tokens/userinfo simply omit that data.\n\n```csharp\nnew IdentityResource(\"profile\", /* ... */) { Required = true }; // cannot be declined\nnew ApiScope(\"api.read\") { Required = false };                  // may be declined\n```\n\n### Remembered Consent\n\nEnable persistence with `Client.AllowRememberConsent` (bool) and `Client.ConsentLifetime` (expiry). Granted scopes are stored in the operational (persisted grant) store; set `RememberConsent = true` on the `ConsentResponse` to persist a grant.\n\nIdentityServer **re-prompts** for consent when:\n\n- there is no remembered consent, or it has expired,\n- a new, not-previously-granted scope is requested,\n- the request includes `offline_access`,\n- the request contains a parameterized scope value, or\n- `AllowRememberConsent = false`.\n\n**Device flow**: in IdentityServer v8, device-flow (Device Authorization Grant) consent is **never remembered** — the user consents on every device authorization.\n\n### Revoking Consent\n\nUse `IIdentityServerInteractionService.RevokeUserConsentAsync(clientId)` for the current user. This removes **all** persisted grants for that user/client — remembered consent, reference tokens, and refresh tokens.\n\n```csharp\nawait _interaction.RevokeUserConsentAsync(\"web.app\");\n```\n\n### Consent Page Flow\n\n```csharp\n// 1. Get authorization context\nvar context = await _interaction.GetAuthorizationContextAsync(returnUrl);\n\n// 2. Show user: client info, requested scopes/resources\n// context.Client — the requesting client\n// Use IClientStore and IResourceStore for additional details\n\n// 3. User grants or denies consent\nawait _interaction.GrantConsentAsync(context, new ConsentResponse\n{\n    ScopesValuesConsented = new[] { \"openid\", \"profile\", \"api1\" },\n    RememberConsent = true  // persist for future requests\n});\n\n// 4. Redirect back\nreturn Redirect(returnUrl);\n```\n\n### Denying Consent\n\n```csharp\nawait _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied);\n```\n\n### Validating returnUrl\n\n```csharp\n// Use interaction service\nif (await _interaction.IsValidReturnUrl(returnUrl))\n{\n    return Redirect(returnUrl);\n}\n// Or check if GetAuthorizationContextAsync returns non-null\n```\n\n## User Registration (prompt=create)\n\nThe `prompt=create` OIDC parameter sends the user straight to a registration page instead of login.\n\n### Host Configuration\n\nSet `CreateAccountUrl` in `AddIdentityServer`. This makes IdentityServer advertise `create` in `prompt_values_supported` in discovery. If unset, `prompt=create` is **ignored and not advertised**.\n\n```csharp\n// Program.cs\nbuilder.Services.AddIdentityServer(options =>\n{\n    options.UserInteraction.CreateAccountUrl = \"/Account/Register\";\n});\n```\n\n`prompt=create` must be the **only** prompt value — it cannot be combined with `login`, `consent`, `select_account`, or `none`.\n\n### Triggering Registration from an ASP.NET Core Client\n\n```csharp\nreturn Results.Challenge(\n    new OpenIdConnectChallengeProperties { Prompt = \"create\", RedirectUri = \"/\" },\n    [\"oidc\"]);\n```\n\n### Registration Page Handler (Host)\n\n```csharp\npublic async Task<IActionResult> OnPost(string returnUrl, CancellationToken ct)\n{\n    // Returns null for an invalid returnUrl → prevents open redirect\n    var context = await _interaction.GetAuthorizationContextAsync(returnUrl, ct);\n    if (context is null) return Redirect(\"~/\");\n\n    // Create + persist the local user\n    var user = await _users.CreateAsync(/* ... */);\n\n    // Sign in ONLY after email confirmation / approval / MFA — not on submit\n    await HttpContext.SignInAsync(new IdentityServerUser(user.SubjectId));\n\n    return Redirect(returnUrl);\n}\n```\n\n**Important**: `GetAuthorizationContextAsync` returning `null` signals an invalid `returnUrl` — redirect away instead of trusting it. Establish the session only after any required confirmation/approval/MFA step.\n\n## Error Page\n\n### Configuration\n\n```csharp\n// Program.cs\nbuilder.Services.AddIdentityServer(opt => {\n    opt.UserInteraction.ErrorUrl = \"/path/to/error\";\n    opt.UserInteraction.ErrorId = \"ErrorQueryStringParamName\"; // default: \"errorId\"\n});\n```\n\n### Retrieving Error Details\n\n```csharp\nvar errorContext = await _interaction.GetErrorContextAsync(errorId);\n\n// errorContext contains:\n// - Error (error code)\n// - ErrorDescription\n// - RequestId\n// - ClientId\n// - DisplayMode\n// - UiLocales\n```\n\nErrors are commonly due to misconfiguration. The error page should inform the user something went wrong without exposing sensitive details.\n\n## Federation Gateway and Home Realm Discovery\n\n### What Is a Federation Gateway?\n\nA federation gateway architecture shields clients from authentication complexity. Clients trust only IdentityServer; the gateway coordinates with external providers, handling protocol bridging (OIDC, SAML, WS-Fed), claim transformation, and trust management.\n\n### Home Realm Discovery (HRD) Strategies\n\n| Strategy                       | Description                                        | Best For                               |\n| ------------------------------ | -------------------------------------------------- | -------------------------------------- |\n| Show all providers             | Present a list of available authentication methods | Simple setups with few providers       |\n| Email/identifier-based         | Ask for email, infer provider from domain          | SaaS with corporate federation         |\n| Client hint via `acr_values`   | Client passes `idp:provider_name`                  | Known provider per client/URL          |\n| `IdentityProviderRestrictions` | Restrict available providers per client            | Multi-tenant with per-client providers |\n\n### Restricting Providers Per Client\n\n```csharp\nvar client = new Client\n{\n    ClientId = \"tenant_a_app\",\n    IdentityProviderRestrictions = { \"AAD\", \"local\" }\n    // Only Azure AD and local login are available\n};\n```\n\n### HRD via acr_values\n\nClients can hint at the desired provider:\n\n```\nGET /connect/authorize?\n    client_id=app&\n    acr_values=idp:AAD&\n    ...\n```\n\nYour login page checks `context.IdP` from `GetAuthorizationContextAsync` and can skip the login UI entirely, redirecting straight to the external provider.\n\n## Common Anti-Patterns\n\n- ❌ Parsing `returnUrl` manually to extract authorization parameters\n- ✅ Use `IIdentityServerInteractionService.GetAuthorizationContextAsync(returnUrl)`\n\n- ❌ Redirecting to `returnUrl` without validation, enabling open redirect attacks\n- ✅ Validate with `Url.IsLocalUrl()` or `_interaction.IsValidReturnUrl()`\n\n- ❌ Forgetting to delete the external authentication cookie after callback processing\n- ✅ Always call `HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme)`\n\n- ❌ Using front-channel logout across different sites/domains (cookie/iframe issues)\n- ✅ Use back-channel logout for cross-site architectures\n\n- ❌ Issuing the authentication session without a `sub` claim\n- ✅ The `sub` claim is required — it uniquely identifies the user and must never change\n\n- ❌ Hardcoding external provider list without checking both static schemes and dynamic providers\n- ✅ Query `IAuthenticationSchemeProvider` for static schemes and `IIdentityProviderStore` for dynamic providers\n\n## Common Pitfalls\n\n1. **Login page does not preserve `returnUrl`**: The `returnUrl` must survive across all page transitions (post-backs, external redirects, MFA steps). Store it in hidden form fields, route data, or the `AuthenticationProperties.Items` dictionary.\n\n2. **Cookie handler `LoginPath` mismatch**: If no explicit `LoginUrl` is configured, IdentityServer infers it from the cookie handler's `LoginPath`. Make sure the cookie handler `LoginPath` matches your actual login page route. The `LogoutUrl` is not inferred from the cookie handler — it must always be set explicitly via `opt.UserInteraction.LogoutUrl`.\n\n3. **SignOutScheme differs with ASP.NET Identity**: When using ASP.NET Identity, the `SignOutScheme` for external providers should be `IdentityConstants.ApplicationScheme`, not `IdentityServerConstants.SignoutScheme`.\n\n4. **Consent persistence is temporary by default**: The consent result between the consent page and authorization endpoint is stored in a cookie. For custom persistence, implement `IConsentMessageStore`.\n\n5. **Error messages are deliberately brief**: For security, error messages returned to clients are minimal. Check the IdentityServer logs (at `Debug` level) for full error details.\n\n6. **External provider `sub` is provider-specific**: The `sub` claim from an external provider is that provider's unique ID. Map it to your local user database — do not use it directly as the IdentityServer `sub`.\n"
}

SHA-256: 07fb7809805fbe51c3f72b3d329954ab04164581c84b76755200b95d148fe1b8