diff --git a/IdentityServer/v8/Directory.Packages.props b/IdentityServer/v8/Directory.Packages.props index 30cf1fab..43cb5431 100644 --- a/IdentityServer/v8/Directory.Packages.props +++ b/IdentityServer/v8/Directory.Packages.props @@ -49,8 +49,8 @@ - - + + diff --git a/IdentityServer/v8/MultiSpace/Directory.Build.props b/IdentityServer/v8/MultiSpace/Directory.Build.props new file mode 100644 index 00000000..0ae9b546 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/Directory.Build.props @@ -0,0 +1,8 @@ + + + + net10.0 + enable + enable + + diff --git a/IdentityServer/v8/MultiSpace/Directory.Packages.props b/IdentityServer/v8/MultiSpace/Directory.Packages.props new file mode 100644 index 00000000..bf097760 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/Directory.Packages.props @@ -0,0 +1,26 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/IdentityServer/v8/MultiSpace/Documentation.md b/IdentityServer/v8/MultiSpace/Documentation.md new file mode 100644 index 00000000..4ae76aff --- /dev/null +++ b/IdentityServer/v8/MultiSpace/Documentation.md @@ -0,0 +1,408 @@ +# Multi-Space + +> This document describes pre-release multi-space functionality. APIs and behavior may change before general availability. + +## Overview + +Duende MultiSpace lets a single Duende IdentityServer host serve multiple isolated spaces. Each space represents a separately addressable IdentityServer instance within the same deployment. + +A space can have its own issuer, clients, resources, users, sessions, and operational data. For each request, MultiSpace resolves the current space and makes that space available to IdentityServer and application code through a request-scoped context. + +This allows one IdentityServer deployment to serve multiple tenants, organizations, environments, or customer partitions without deploying a separate IdentityServer host for each one. + +In this pre-release, MultiSpace focuses on the core building blocks required to host multiple IdentityServer spaces: + +- defining spaces +- resolving the current space from an incoming request +- exposing the resolved space to IdentityServer and application code +- routing storage and configuration through the resolved space + +## When to Use Multi-Space + +Use MultiSpace when multiple tenants or organizations can safely share the same IdentityServer deployment, but need separate IdentityServer configuration and runtime state. + +Common scenarios include: + +- hosting IdentityServer for multiple customer tenants from one deployment +- giving each organization its own issuer, clients, resources, and users +- separating environments, business units, regions, or brands without operating separate IdentityServer deployments +- supporting customers that require logical isolation but do not require a dedicated deployment +- reducing operational overhead when many IdentityServer instances would otherwise have the same hosting, deployment, and upgrade lifecycle + +MultiSpace requires the Duende.Storage based architecture. It cannot be used with the Entity Framework based configuration or operational stores. If your IdentityServer host uses Entity Framework stores directly, migrate to the Duende.Storage based architecture before enabling MultiSpace. + +MultiSpace can also be introduced gradually. A deployment can start with a single space and add more spaces later, without changing the overall hosting model. + +MultiSpace uses database pools as the storage isolation model. Each space is assigned to a pool, and storage operations are routed through the resolved space to the database pool for that space. + +MultiSpace is not required when all clients, resources, users, and operational data belong to the same authority. A normal single-space IdentityServer deployment is simpler and remains the right choice for those applications. + +Use separate IdentityServer deployments when tenants or organizations require separate hosting, separate upgrade schedules, separate operational ownership, or hard infrastructure isolation. + +## Core Concepts + +### Space + +A space is an isolated IdentityServer authority within a MultiSpace deployment. Each space has a stable space identifier and can have its own issuer, configuration, clients, resources, users, sessions, and operational data. + +### Space Context + +The space context is the request-scoped state that contains the currently resolved space. + +After the MultiSpace resolution middleware identifies a space, application code and IdentityServer services can access the current space through `ISpaceContextAccessor`. + +### Space Resolution + +Space resolution is the process of identifying which space an incoming request maps to. + +Requests go through the MultiSpace resolution middleware. The middleware attempts to identify the space for the request by comparing request information, such as the host or path, against the configured match patterns for known spaces. + +If the middleware identifies a matching space, it stores that space in the request's space context. If no matching space is found, the request continues without a current space. + +### Space Store + +The space store contains the known spaces and their configuration. MultiSpace uses the space store during request resolution and when managing spaces. + +### Pool + +A pool represents a database pool used for storage isolation. Each space is assigned to a pool. When IdentityServer reads or writes space-aware data, storage is routed to the pool for the current space. + +### Match Pattern + +A match pattern describes how incoming requests are associated with a space. A space can have one or more match patterns, such as host-based or path-based patterns. + +## Request Flow + +MultiSpace resolves the current space early in the ASP.NET Core request pipeline. IdentityServer endpoints and application code later in the pipeline can then use the resolved space. + +```mermaid +flowchart LR + Request[Incoming request] --> Middleware[MultiSpace resolution middleware] + Middleware --> Patterns[Match host or path] + Patterns --> Context[Space context] + Context --> IdentityServer[IdentityServer services] + Context --> Storage[Database pool] +``` + +The request flow is: + +1. A request enters the ASP.NET Core pipeline. +2. The MultiSpace resolution middleware compares the request to the configured match patterns. +3. If a match is found, the middleware loads the matching space. +4. The middleware stores the space in the request's space context. +5. IdentityServer services use the current space to select the correct configuration, issuer, runtime state, and database pool. +6. Application code can read the current space from `ISpaceContextAccessor`. + +Add the MultiSpace resolution middleware before IdentityServer handles requests. If the application uses endpoint routing, add the MultiSpace resolution middleware before routing so path-based space resolution can rewrite the request path before route matching occurs. + +## Configuring Multi-Space + +MultiSpace is added to the application's service collection. IdentityServer's configuration and operational data must use the Duende.Storage based stores. + +```csharp +using Duende.MultiSpace; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMultiSpace(); + +builder.Services + .AddIdentityServer() + .AddConfigurationStorage() + .AddOperationalStorage(); +``` + +MultiSpace requires the Duende.Storage based architecture. Do not configure the Entity Framework configuration or operational stores when using MultiSpace. + +Configure MultiSpace options with the standard options pattern: + +```csharp +using Duende.MultiSpace; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(options => +{ + options.SpacePathPrefix = "/t"; + options.LocalCacheExpiration = TimeSpan.FromSeconds(30); + options.Expiration = TimeSpan.FromMinutes(30); + options.FallbackToDefault = false; +}); +``` + +`SpacePathPrefix` controls the path prefix used for path-based space resolution. The default value is `/t`. + +`LocalCacheExpiration` controls the local in-process cache duration for resolved space data. + +`Expiration` controls the distributed cache duration for resolved space data. + +`FallbackToDefault` controls what happens when a request cannot be resolved to a space. When `false`, unresolved requests do not get a current space and return a 404. When `true`, unresolved requests use the default space. + +Add the MultiSpace resolution middleware before IdentityServer: + +```csharp +using Duende.MultiSpace; + +var app = builder.Build(); + +app.UseMultiSpaceResolution(); + +app.UseIdentityServer(); + +app.Run(); +``` + +## Resolving the Current Space + +MultiSpace resolves spaces by comparing each request with the match patterns configured for known spaces. + +The current public match pattern model supports origin matching, path matching, or both: + +```csharp +using Duende.MultiSpace; + +var hostPattern = new SpaceMatchPattern +{ + Origin = "https://tenant-a.example.com" +}; + +var pathPattern = new SpaceMatchPattern +{ + Path = "/tenant-a" +}; + +var hostAndPathPattern = new SpaceMatchPattern +{ + Origin = "https://identity.example.com", + Path = "/tenant-a" +}; +``` + +### Path-based resolution + +Path-based resolution uses `SpacePathPrefix` and the configured path match pattern. The default prefix is `/t`. + +For example, a space with this match pattern: + +```csharp +using Duende.MultiSpace; + +var pattern = new SpaceMatchPattern +{ + Path = "/acme" +}; +``` + +matches requests under `/t/acme`, such as: + +```text +https://identity.example.com/t/acme/.well-known/openid-configuration +https://identity.example.com/t/acme/connect/authorize +https://identity.example.com/t/acme/connect/token +``` + +When the middleware resolves a path-based space, it rewrites the request so downstream routing sees the IdentityServer path without the space prefix. For example, `/t/acme/connect/authorize` is rewritten so downstream middleware sees `/connect/authorize`. + +### Host-based resolution + +Host-based resolution uses the request origin. The origin includes the scheme, host, and optional port. + +```csharp +using Duende.MultiSpace; + +var pattern = new SpaceMatchPattern +{ + Origin = "https://acme.example.com" +}; +``` + +This pattern matches requests to `https://acme.example.com`. + +### Origin and path resolution + +A match pattern can include both `Origin` and `Path`. Use this when the same path should only resolve for a specific origin. + +```csharp +using Duende.MultiSpace; + +var pattern = new SpaceMatchPattern +{ + Origin = "https://identity.example.com", + Path = "/acme" +}; +``` + +This pattern matches requests under `/t/acme` only when the request origin is `https://identity.example.com`. + +### Fallback to the default space + +By default, requests that do not match a configured space are not assigned a current space and will return a 404. + +You can change this behavior with `MultiSpaceOptions.FallbackToDefault`. When `FallbackToDefault` is `true`, unresolved requests are assigned `SpaceId.Default`. + +```csharp +using Duende.MultiSpace; + +builder.Services.Configure(options => +{ + options.FallbackToDefault = true; +}); +``` + +Use fallback when you are adding MultiSpace to an existing single-space IdentityServer deployment and want unmatched requests to continue using the default space. Disable fallback when every IdentityServer request must match an explicit space. + +## Creating and Managing Spaces + +Use `ISpaceAdmin` to create, read, update, query, and delete spaces. + +The following example creates a space that resolves from the `/t/acme` path: + +```csharp +using Duende.MultiSpace; + +using var scope = app.Services.CreateScope(); + +var spaces = scope.ServiceProvider.GetRequiredService(); + +var result = await spaces.CreateAsync( + new CreateSpaceConfiguration + { + Name = "Acme", + MatchPatterns = + [ + new SpaceMatchPattern + { + Path = "/acme" + } + ] + }, + cancellationToken); + +if (!result.IsSuccess) +{ + throw new InvalidOperationException( + string.Join(Environment.NewLine, result.Errors!.Select(error => error.Message))); +} +``` + +The following example creates a space that resolves from a dedicated origin: + +```csharp +using Duende.MultiSpace; + +using var scope = app.Services.CreateScope(); + +var spaces = scope.ServiceProvider.GetRequiredService(); + +var result = await spaces.CreateAsync( + new CreateSpaceConfiguration + { + Name = "Acme", + MatchPatterns = + [ + new SpaceMatchPattern + { + Origin = "https://acme.example.com" + } + ] + }, + cancellationToken); + +if (!result.IsSuccess) +{ + throw new InvalidOperationException( + string.Join(Environment.NewLine, result.Errors!.Select(error => error.Message))); +} +``` + +When `PoolId` is omitted, MultiSpace assigns a pool automatically. + +```csharp +using Duende.MultiSpace; + +var result = await spaces.CreateAsync( + new CreateSpaceConfiguration + { + Name = "Acme", + MatchPatterns = [new SpaceMatchPattern { Path = "/acme" }] + }, + cancellationToken); +``` + +You can also assign a specific pool ID: + +```csharp +using Duende.MultiSpace; + +var result = await spaces.CreateAsync( + new CreateSpaceConfiguration + { + Name = "Acme", + PoolId = (PoolId)1, + MatchPatterns = [new SpaceMatchPattern { Path = "/acme" }] + }, + cancellationToken); +``` + +## Using the Current Space in Application Code + +Application code can read the current space from `ISpaceContextAccessor`. + +Use `IsSpaceIdConfigured()` before calling `GetSpaceId()` if the code can run without a resolved space: + +```csharp +using Duende.MultiSpace; + +app.MapGet("/space", (ISpaceContextAccessor spaceContext) => +{ + if (!spaceContext.IsSpaceIdConfigured()) + { + return Results.NotFound(); + } + + var spaceId = spaceContext.GetSpaceId(); + + return Results.Ok(new + { + SpaceId = spaceId.Value + }); +}); +``` + +If the code should fall back to the default space when no space has been resolved, use `GetSpaceIdOrDefault()`: + +```csharp +using Duende.MultiSpace; + +app.MapGet("/space-or-default", (ISpaceContextAccessor spaceContext) => +{ + var spaceId = spaceContext.GetSpaceIdOrDefault(); + + return Results.Ok(new + { + SpaceId = spaceId.Value + }); +}); +``` + +## Limitations in This Pre-Release + +This pre-release is intended to validate the core MultiSpace model for IdentityServer. The following limitations apply: + +- MultiSpace requires Duende.Storage based configuration and operational stores. +- Entity Framework based IdentityServer stores are not supported. +- Database pools are the supported storage isolation model. +- Space resolution is based on configured origin and path match patterns. +- Custom resolution strategies are not documented here. +- The registration order between MultiSpace and storage is being verified separately. Until that is resolved, this document does not make an order-specific guarantee beyond the examples shown here. + +## Next Steps + +After configuring MultiSpace: + +1. Configure Duende.Storage for the database provider used by the host. +2. Add `UseMultiSpaceResolution()` before IdentityServer in the ASP.NET Core pipeline. +3. Create the initial space. +4. Add match patterns for the hosts or paths that should resolve to that space. +5. Verify the discovery document, authorize endpoint, token endpoint, and operational flows for each space. +6. Add additional spaces when you need another isolated IdentityServer authority in the same deployment. \ No newline at end of file diff --git a/IdentityServer/v8/MultiSpace/MultiSpace.slnx b/IdentityServer/v8/MultiSpace/MultiSpace.slnx new file mode 100644 index 00000000..cd659385 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/MultiSpace.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/IdentityServer/v8/MultiSpace/README.md b/IdentityServer/v8/MultiSpace/README.md new file mode 100644 index 00000000..2a7c7199 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/README.md @@ -0,0 +1,138 @@ +# MultiSpace Sample + +This sample demonstrates [Duende MultiSpace](Documentation.md) — running multiple isolated IdentityServer spaces within a single ASP.NET Core host. Each space has its own users, clients, identity providers, and configuration data, completely isolated from other spaces. + +## What This Sample Shows + +| Scenario | How It's Demonstrated | +|----------|----------------------| +| **Host-based space resolution** | `space1.dev.localhost:5000` and `space2.dev.localhost:5000` each resolve to their own space | +| **Path-based space resolution** | `localhost:5000/t/space3` resolves to space3 via the `/t` path prefix | +| **Default/fallback space** | `localhost:5000` resolves to the default space | +| **Per-space user isolation** | Each space has its own users; a user in one space cannot sign into another | +| **Per-space client isolation** | Each space has its own client credentials clients | +| **Shared resources across spaces** | An `example-client` and `example-user` are created in every space to show how the same logical entity must be explicitly provisioned per space | +| **Dynamic external identity providers** | Each space has its own OIDC provider (pointing to demo.duendesoftware.com) | +| **Space claim in tokens** | A custom profile service and token request validator add a `space` claim to all issued tokens | +| **Client credentials token request** | The home page requests tokens server-side and displays decoded JWT header, payload, and claims | +| **User Management integration** | Users are created and authenticated via Duende.UserManagement with password authentication | + +## Spaces + +| Space | Resolution | URL | +|-------|-----------|-----| +| Default | Fallback (no explicit match) | `https://localhost:5000` | +| space1 | Host-based | `https://space1.dev.localhost:5000` | +| space2 | Host-based | `https://space2.dev.localhost:5000` | +| space3 | Path-based | `https://ignored.localhost:5000/t/space3` | + +The sample is configured to fallback if no space matches to the default space. This is analogous +to when you use a single-spaced IdentityServer with multiple issuers: They all use the same configuration data. + +Space3 is configured to use only path based routing. This means that it completely ignores +the hostname. The reason we chose a different domain name (ignored.localhost) is becuase cookies +are tied to a domain name. In this case, the cookie for the default space is also valid for space 3, which is not desirable. + +Since origin based routing takes precedence, navigating to https://space1.dev.localhost:5000/t/space3 will return 404. This is becuase it's not clear which space to select (space1 via origin or space 3 via path) + +## Demo Credentials + +All passwords are `Pa$$Word123`. + +| Space | Space-Specific User | Shared User | +|-------|-------------------|-------------| +| Default | `default-user` | `example-user` | +| space1 | `space1-user` | `example-user` | +| space2 | `space2-user` | `example-user` | +| space3 | _(none)_ | `example-user` | + +### Clients + +To demonstrate how to use client credentials, we've also setup several clients. + +Each space has two clients: +a space specific client (IE: space1-client) and the 'example-client'. + +| Client ID | Spaces | Grant Type | Secret | +|-----------|--------|-----------|--------| +| `default-client` | Default only | client_credentials | `secret` | +| `space1-client` | space1 only | client_credentials | `secret` | +| `space2-client` | space2 only | client_credentials | `secret` | +| `space3-client` | space3 only | client_credentials | `secret` | +| `example-client` | All spaces | client_credentials | `secret` | + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) + +## Running the Sample + +```bash +cd src/MultiSpace +dotnet run +``` + +The app launches at `https://localhost:5000`. + +### Accessing All Spaces + +1. **Default space**: Navigate to `https://localhost:5000` +2. **space1**: Navigate to `https://space1.dev.localhost:5000` +3. **space2**: Navigate to `https://space2.dev.localhost:5000` +4. **space3**: Navigate to `https://localhost:5000/t/space3` + +### HTTPS Certificate + +The sample uses the ASP.NET Core development certificate. Trust it with: + +```bash +dotnet dev-certs https --trust +``` + +## External Login (OIDC) + +Each space has a dynamic OIDC identity provider configured pointing to `https://demo.duendesoftware.com`. Click the external provider button on the login page to authenticate via the Duende demo server. + +## How It Works + +### Space Resolution + +MultiSpace resolves the current space early in the middleware pipeline via `app.UseMultiSpaceResolution()`. This must run before `app.UseIdentityServer()` so that all IdentityServer operations use the correct space context. + +- **Host-based**: The request origin is matched against configured `SpaceMatchPattern.Origin` values +- **Path-based**: Requests under `/t/{space-path}` are matched and the path is rewritten for downstream routing + +See [Documentation.md](Documentation.md) for full details on resolution, configuration options, and the `ISpaceContextAccessor` API. + +### Data Isolation + +All data is isolated per space. The seed data demonstrates this by: +- Creating the same `example-user` and `example-client` independently in each space +- Each space has its own user schema, API scopes, and identity providers +- A user authenticated in one space has no session or identity in another + +### Space Claim Augmentation + +Two custom services add a `space` claim to issued tokens: + +1. **`SpaceClaimAugmentationProfileService`** — Wraps `UserManagementProfileService` and adds the space claim for user tokens +2. **`AddSpaceNameToClaimsRequestValidator`** — Adds the space claim during client credentials token requests + +This allows downstream APIs to know which space a token was issued from. + +## Running Tests + +The E2E tests use [Playwright for .NET](https://playwright.dev/dotnet/) and [Aspire Testing](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/testing): + +```bash +# Install Playwright browsers (first time only) +pwsh tests/MultiSpace.PlaywrightTests/bin/Debug/net10.0/.playwright/package/bin/playwright.ps1 install + +# Run tests +dotnet test tests/MultiSpace.PlaywrightTests +``` +## Further Reading + +- [MultiSpace Documentation](Documentation.md) — Full feature documentation including configuration options, space resolution, and management APIs +- [Duende IdentityServer Documentation](https://docs.duendesoftware.com/identityserver/) +- [Duende User Management](https://docs.duendesoftware.com/user-management/) diff --git a/IdentityServer/v8/MultiSpace/aspire/AppHost/AppHost.csproj b/IdentityServer/v8/MultiSpace/aspire/AppHost/AppHost.csproj new file mode 100644 index 00000000..50550b9c --- /dev/null +++ b/IdentityServer/v8/MultiSpace/aspire/AppHost/AppHost.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + + + + + + + + + + + diff --git a/IdentityServer/v8/MultiSpace/aspire/AppHost/Program.cs b/IdentityServer/v8/MultiSpace/aspire/AppHost/Program.cs new file mode 100644 index 00000000..53676745 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/aspire/AppHost/Program.cs @@ -0,0 +1,11 @@ +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddProject("multispace") + .WithEndpoint("https", e => + { + e.Port = 5000; + e.TargetPort = 5000; + e.IsProxied = false; + }); + +builder.Build().Run(); diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/MultiSpace.csproj b/IdentityServer/v8/MultiSpace/src/MultiSpace/MultiSpace.csproj new file mode 100644 index 00000000..dd9cc161 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/MultiSpace.csproj @@ -0,0 +1,16 @@ + + + + $(NoWarn);NU1605 + + + + + + + + + + + + diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/ExternalLoginCallback.cshtml b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/ExternalLoginCallback.cshtml new file mode 100644 index 00000000..803350df --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/ExternalLoginCallback.cshtml @@ -0,0 +1,14 @@ +@page +@model MultiSpace.Pages.Account.ExternalLoginCallbackModel +@{ + ViewData["Title"] = "External Login"; +} + +@if (Model.ErrorMessage != null) +{ +
+

External Login Failed

+ + Back to login +
+} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/ExternalLoginCallback.cshtml.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/ExternalLoginCallback.cshtml.cs new file mode 100644 index 00000000..ae2a330a --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/ExternalLoginCallback.cshtml.cs @@ -0,0 +1,133 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Security.Claims; +using Duende.IdentityModel; +using Duende.IdentityServer; +using Duende.Storage.EntityAttributeValue; +using Duende.UserManagement; +using Duende.UserManagement.Authentication.External; +using Duende.UserManagement.Profiles; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MultiSpace.Seed; + +namespace MultiSpace.Pages.Account; + +public sealed class ExternalLoginCallbackModel( + IExternalAuthenticator externalAuthenticator, + IUserProfileSelfService profileSelfService) : PageModel +{ + public string? ErrorMessage { get; private set; } + + public async Task OnGetAsync(string? returnUrl) + { + var ct = HttpContext.RequestAborted; + + var result = await HttpContext.AuthenticateAsync( + IdentityServerConstants.ExternalCookieAuthenticationScheme); + + if (!result.Succeeded || result.Principal is null) + { + ErrorMessage = "External authentication failed."; + return Page(); + } + + var principal = result.Principal; + var providerName = result.Properties?.Items[".AuthScheme"]; + var externalSub = principal.FindFirst(JwtClaimTypes.Subject)?.Value + ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (providerName is null || externalSub is null) + { + ErrorMessage = "External authentication did not return required claims."; + return Page(); + } + + var address = new ExternalAuthenticatorAddress( + ExternalAuthenticatorName.Create(providerName), + OpaqueSubjectId.Create(externalSub)); + + var authResult = await externalAuthenticator.TryAuthenticateAsync(address, ct); + + if (authResult is not ExternalAuthenticationResult.Success success) + { + ErrorMessage = "Could not authenticate with external provider."; + return Page(); + } + + var userId = success.UserSubjectId; + + // Ensure a profile exists for this user + var existingProfile = await profileSelfService.TryGetAsync(userId, ct); + if (existingProfile is null) + { + var name = principal.FindFirst(JwtClaimTypes.Name)?.Value + ?? principal.FindFirst(ClaimTypes.Name)?.Value + ?? "name"; + + var email = principal.FindFirst(JwtClaimTypes.Email)?.Value + ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "test@test.nl"; + + var schema = await profileSelfService.GetSchemaAsync(ct); + var attributes = new AttributeValueCollection(schema); + attributes.Set(DemoUserAttributes.UserName, name); + if (email is not null) + { + attributes.Set(DemoUserAttributes.Email, email); + } + + var saved = await profileSelfService.TryCreateAsync(userId, attributes.Validate(), ct); + if (saved is null) + { + ErrorMessage = "Could not create user profile."; + return Page(); + } + } + + await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); + + // Carry forward useful claims from the external provider + var additionalClaims = new List + { + new(JwtClaimTypes.AuthenticationMethod, "external"), + new(JwtClaimTypes.IdentityProvider, providerName) + }; + + // Forward name, email, and other standard claims from the external principal + var claimTypesToForward = new[] + { + JwtClaimTypes.Name, ClaimTypes.Name, + JwtClaimTypes.Email, ClaimTypes.Email, + JwtClaimTypes.GivenName, ClaimTypes.GivenName, + JwtClaimTypes.FamilyName, ClaimTypes.Surname, + JwtClaimTypes.Picture + }; + + foreach (var claim in principal.Claims) + { + if (claimTypesToForward.Contains(claim.Type)) + { + additionalClaims.Add(new Claim(claim.Type, claim.Value)); + } + } + + var identityServerUser = new IdentityServerUser(userId.ToString()) + { + AdditionalClaims = additionalClaims + }; + + var authProperties = new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddHours(8), + IssuedUtc = DateTimeOffset.UtcNow, + AllowRefresh = true + }; + + await HttpContext.SignInAsync(identityServerUser, authProperties); + + return LocalRedirect(Url.IsLocalUrl(returnUrl) ? returnUrl! : Url.Content("~/")); + } +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Login.cshtml b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Login.cshtml new file mode 100644 index 00000000..d17963c5 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Login.cshtml @@ -0,0 +1,72 @@ +@page +@model MultiSpace.Pages.Account.LoginModel +@{ + ViewData["Title"] = "Sign in"; +} + + diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Login.cshtml.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Login.cshtml.cs new file mode 100644 index 00000000..3b585180 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Login.cshtml.cs @@ -0,0 +1,169 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using Duende.IdentityModel; +using Duende.IdentityServer; +using Duende.IdentityServer.Stores; +using Duende.MultiSpace; +using Duende.UserManagement; +using Duende.UserManagement.Authentication.Passwords; +using Duende.UserManagement.Profiles; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MultiSpace.Seed; + +namespace MultiSpace.Pages.Account; + +public class LoginModel( + IPasswordAuthenticator passwordAuth, + IUserProfileSelfService profileSelfService, + ISpaceContextAccessor spaceContextAccessor, + ISpaceStore spaceStore, + IIdentityProviderStore identityProviders, + IConfiguration configuration) : PageModel +{ + [BindProperty, Required] + public string Username { get; set; } = string.Empty; + + [BindProperty, Required] + public string Password { get; set; } = string.Empty; + + [BindProperty] + public string? ReturnUrl { get; set; } + + public string? ErrorMessage { get; private set; } + + /// Demo username for the current space, or null if no demo user is seeded. + public string? DemoUsername { get; private set; } + + /// Demo password (shared across all spaces). + public string DemoPassword => configuration["Demo:Password"] ?? DemoCredentials.Password; + + public string SpaceName { get; private set; } = "Default"; + + public List Schemes { get; private set; } = new(); + + public async Task OnGet(string? returnUrl, CancellationToken ct) + { + ReturnUrl = Url.IsLocalUrl(returnUrl) ? returnUrl : null; + await PopulateViewState(ct); + } + + public async Task OnPostAsync(CancellationToken ct) + { + await PopulateViewState(ct); + + if (!ModelState.IsValid) + { + return Page(); + } + + if (!NonValidatedPassword.TryCreate(Password, out var passwordValue)) + { + ErrorMessage = "Invalid username or password."; + return Page(); + } + + var result = await passwordAuth.TryAuthenticateAsync( + DemoUserAttributes.UserName, + Username, + passwordValue, + HttpContext.RequestAborted); + + return result switch + { + PasswordAuthenticationResult.Success success => await CompleteSignIn(success.UserSubjectId), + PasswordAuthenticationResult.Expired expired => await CompleteSignIn(expired.UserSubjectId), + PasswordAuthenticationResult.Failure => Fail("Invalid username or password."), + _ => Fail("Unexpected authentication result.") + }; + } + + public async Task OnPostExternalLoginAsync(string provider, CancellationToken ct) + { + await PopulateViewState(ct); + + // Validate provider against current space's registered schemes + if (!Schemes.Contains(provider)) + { + return Fail($"External provider '{provider}' is not available for this space."); + } + + var callbackUrl = Url.Page( + "/Account/ExternalLoginCallback", + values: new { returnUrl = ReturnUrl }); + + var properties = new AuthenticationProperties + { + RedirectUri = callbackUrl + }; + + return Challenge(properties, provider); + } + + private async Task CompleteSignIn(UserSubjectId subjectId) + { + var ct = HttpContext.RequestAborted; + var claims = new List + { + new(JwtClaimTypes.AuthenticationMethod, OidcConstants.AuthenticationMethods.Password) + }; + + // Load profile attributes (name, email, etc.) and add as claims + var profile = await profileSelfService.TryGetAsync(subjectId, ct); + if (profile is not null) + { + foreach (var (code, value) in profile.Attributes) + { + var val = value.UntypedValue?.ToString(); + if (!string.IsNullOrWhiteSpace(val)) + { + claims.Add(new Claim(code.ToString(), val)); + } + } + } + + var space = await spaceStore.TryGetSpace(spaceContextAccessor.GetSpaceId(), ct); + claims.Add(new Claim("space", space?.Name ?? "Default")); + + var identityServerUser = new IdentityServerUser(subjectId.Value) + { + AdditionalClaims = claims + }; + + var authProperties = new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddHours(8), + IssuedUtc = DateTimeOffset.UtcNow, + AllowRefresh = true + }; + + await HttpContext.SignInAsync(identityServerUser, authProperties); + + return LocalRedirect(Url.IsLocalUrl(ReturnUrl) ? ReturnUrl : Url.Content("~/")); + } + + private IActionResult Fail(string message) + { + ErrorMessage = message; + return Page(); + } + + private async Task PopulateViewState(CancellationToken ct) + { + var spaceId = spaceContextAccessor.GetSpaceId(); + var space = await spaceStore.TryGetSpace(spaceId, ct); + var spaceName = space?.Name ?? "Default"; + // The space name is encoded in the space id for non-default spaces + SpaceName = spaceName; + DemoUsername = DemoCredentials.GetUsernameForSpace(spaceName); + + var schemes = await identityProviders.GetAllSchemeNamesAsync(ct); + + Schemes = schemes.Select(x => x.Scheme).ToList(); + } +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Logout.cshtml b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Logout.cshtml new file mode 100644 index 00000000..964fe36f --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Logout.cshtml @@ -0,0 +1,16 @@ +@page +@model MultiSpace.Pages.Account.LogoutModel +@{ + ViewData["Title"] = "Sign out"; +} + +
+

Sign out

+

Are you sure you want to sign out?

+ +
+ + + Cancel +
+
diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Logout.cshtml.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Logout.cshtml.cs new file mode 100644 index 00000000..bc168f1b --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Account/Logout.cshtml.cs @@ -0,0 +1,26 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Duende.IdentityServer; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace MultiSpace.Pages.Account; + +public class LogoutModel : PageModel +{ + [BindProperty] + public string? ReturnUrl { get; set; } + + public void OnGet(string? returnUrl) + { + ReturnUrl = Url.IsLocalUrl(returnUrl) ? returnUrl : null; + } + + public async Task OnPostAsync() + { + await HttpContext.SignOutAsync(IdentityServerConstants.DefaultCookieAuthenticationScheme); + return LocalRedirect(Url.IsLocalUrl(ReturnUrl) ? ReturnUrl! : Url.Content("~/")); + } +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Index.cshtml b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Index.cshtml new file mode 100644 index 00000000..ab8e6e5b --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Index.cshtml @@ -0,0 +1,172 @@ +@page +@model MultiSpace.Pages.IndexModel +@{ + ViewData["Title"] = Model.SpaceName != null ? $"Space: {Model.SpaceName}" : "Home"; +} + +
+ +
+

+ MultiSpace Demo + @if (Model.SpaceName != null) + { + @Model.SpaceName + } +

+ + @if (Model.SpaceId != null) + { +

Space ID: @Model.SpaceId

+ } + +
+ +
+

All Spaces

+ +
+ +
+

Authentication

+ @if (Model.IsSignedIn) + { +

Signed in as @Model.SignedInUser

+ Sign out + + @if (Model.UserClaims.Count > 0) + { +

Your Claims

+ + + + + + @foreach (var (type, value) in Model.UserClaims) + { + + } + +
TypeValue
@type@value
+ } + } + else + { +

You are not signed in.

+ Sign in + } +
+ +
+

Client Credentials Token Request

+

+ Requests a client_credentials access token entirely server-side. + The browser never sends or receives the client secret. +

+ +
+ @Html.AntiForgeryToken() + +
+ Select client + + +
+ + +
+ + @if (Model.TokenError != null) + { +
+ Error: @Model.TokenError + @if (Model.TokenRawResponse != null) + { +
@Model.TokenRawResponse
+ } +
+ } + + @if (Model.TokenDisplay != null) + { + var td = Model.TokenDisplay; +
+

Token Output

+ @if (Model.TokenClientId != null) + { +

Client ID: @Model.TokenClientId

+ } + + @* Raw token *@ +

Raw Access Token

+
+ @td.RawToken +
+ + @if (td.IsOpaqueToken) + { +
+ Note: This is an opaque/reference token and cannot be decoded + as a JWT. Use the IdentityServer introspection endpoint to inspect its claims. +
+ } + else + { + @* JWT header *@ +

JWT Header

+
@td.HeaderJson
+ + @* JWT payload *@ +

JWT Payload

+
@td.PayloadJson
+ + @* Claims table *@ + @if (td.Claims is { Count: > 0 }) + { +

Claims

+ + + + + + @foreach (var claim in td.Claims) + { + + } + +
Claim TypeValue
@claim.Type@claim.Value
+ } + } +
+ } +
+ +
diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Index.cshtml.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Index.cshtml.cs new file mode 100644 index 00000000..77f826bc --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Index.cshtml.cs @@ -0,0 +1,247 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.IdentityModel.Tokens.Jwt; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MultiSpace.Seed; +using System.Security.Claims; +using Duende.IdentityModel.Client; +using Duende.MultiSpace; +using Duende.Storage.Querying; + +namespace MultiSpace.Pages; + +/// +/// Index page model - home page for every space. +/// +/// Responsibilities: +/// • Display current space id, name, and routing explanation. +/// • Render cross-space navigation links. +/// • Show demo credentials for the current space. +/// • Login/logout area (current user's claims when signed in). +/// • Server-side token request form (client id/secret never sent to browser). +/// • Token output: raw token, decoded JWT header/payload, claims table. +/// +public sealed class IndexModel( + IHttpContextAccessor httpContextAccessor, + ISpaceContextAccessor spaceContextAccessor, + ISpaceAdmin spaceAdmin, + IHttpClientFactory httpClientFactory) : PageModel +{ + public string? SpaceId { get; private set; } + public string? SpaceName { get; private set; } + public bool IsSignedIn { get; private set; } + public string? SignedInUser { get; private set; } + + /// Claims of the currently signed-in user (empty when not signed in). + public IReadOnlyList<(string Type, string Value)> UserClaims { get; private set; } = []; + + /// All space links for cross-space navigation. + public IReadOnlyList SpaceLinks { get; private set; } = []; + + /// Demo credentials for the current space, or null if no demo user is seeded. + public string? DemoUsername { get; private set; } + + public string? AccessToken { get; set; } + + /// Client id used for the last token request (safe to display). + public string? TokenClientId { get; private set; } + + /// Error message from a failed token request. + public string? TokenError { get; private set; } + + /// Raw JSON response body from the token endpoint. + public string? TokenRawResponse { get; private set; } + + /// Decoded token display (raw token, JWT header/payload, claims table). + public TokenDisplayResult? TokenDisplay { get; private set; } + + /// Which client to use for the token request (bound from form). + [BindProperty] + public string SelectedClient { get; set; } = "space"; + + /// The space-specific client id. + public string SpaceClientId => $"{SpaceName?.ToLowerInvariant()}-client"; + + /// The shared example client id (same across all spaces). + public const string ExampleClientId = "example-client"; + + public async Task OnGetAsync(CancellationToken ct) + { + await PopulateViewStateAsync(ct); + } + + public async Task OnPostRequestTokenAsync(CancellationToken ct) + { + await PopulateViewStateAsync(ct); + + TokenClientId = SelectedClient == "example" ? ExampleClientId : SpaceClientId; + var client = httpClientFactory.CreateClient("TokenRequest"); + var spaceUrl = await GetUriFor(spaceContextAccessor.GetSpaceId(), ct); + var authorityUrl = new Uri(spaceUrl, "connect/token"); + var result = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest() + { + ClientId = TokenClientId, + ClientSecret = "secret", + Address = authorityUrl.ToString(), + GrantType = "client_credentials", + Scope = "scope1" + }, ct); + + TokenRawResponse = result.Raw; + + if (result.IsError) + { + TokenError = result.Error ?? "Token request failed."; + } + else + { + AccessToken = result.AccessToken; + TokenDisplay = BuildTokenDisplay(result.AccessToken); + } + + return Page(); + } + + + private async Task PopulateViewStateAsync(CancellationToken ct) + { + // Space info + SpaceId = spaceContextAccessor.GetSpaceId().ToString(); + var getSpace = await spaceAdmin.GetAsync(spaceContextAccessor.GetSpaceId(), ct); + var allSpaces = await spaceAdmin.QueryAsync(QueryRequest.Create(), ct); + var spaceLinks = new List() + { + new("Default", "https://localhost:5000") + }; + foreach (var spaceItem in allSpaces) + { + var uri = await GetUriFor(spaceItem.Id, ct); + + var spaceLink = new SpaceLink(spaceItem.Name, uri.ToString()); + spaceLinks.Add(spaceLink); + } + + SpaceName = getSpace.Item?.Name ?? "Default"; + + // Demo credentials for the current space + DemoUsername = DemoCredentials.GetUsernameForSpace(SpaceName); + + // Signed-in user info + var user = httpContextAccessor.HttpContext?.User; + IsSignedIn = user?.Identity?.IsAuthenticated == true; + if (IsSignedIn) + { + SignedInUser = user!.FindFirstValue("name") ?? user!.FindFirstValue("sub") ?? "Unknown"; + UserClaims = user!.Claims + .Select(c => (c.Type, c.Value)) + .ToList(); + } + + // Cross-space links + SpaceLinks = spaceLinks; + } + + private async Task GetUriFor(SpaceId spaceId, CancellationToken ct) + { + var getSpaceData = await spaceAdmin.GetAsync(spaceId, ct); + + if (!getSpaceData.Found) + { + return new Uri("https://localhost:5000"); + } + + var url = getSpaceData.Item.MatchPatterns.First() switch + { + { Origin: { } o, Path: { } p } => new Uri(new Uri(o), p), + { Origin: { } o } => new Uri(o), + { Path: { } p } => new Uri(new Uri("https://ignored.dev.localhost:5000"), $"/t{p}/"), + _ => new Uri("https://localhost:5000") + }; + + return url; + } + + /// + /// Attempts to decode the access token as a JWT. If parsing fails (opaque/reference + /// token), returns a result with the raw token and IsOpaqueToken = true. + /// + private static TokenDisplayResult BuildTokenDisplay(string? accessToken) + { + if (string.IsNullOrWhiteSpace(accessToken)) + { + return new TokenDisplayResult( + RawToken: string.Empty, + IsOpaqueToken: true, + HeaderJson: null, + PayloadJson: null, + Claims: []); + } + + try + { + var handler = new JwtSecurityTokenHandler(); + if (handler.CanReadToken(accessToken)) + { + var jwt = handler.ReadJwtToken(accessToken); + + var headerJson = PrettyPrint(jwt.Header.SerializeToJson()); + var payloadJson = PrettyPrint(jwt.Payload.SerializeToJson()); + + var claims = jwt.Claims + .Select(c => new TokenClaim(c.Type, c.Value)) + .ToList(); + + return new TokenDisplayResult( + RawToken: accessToken, + IsOpaqueToken: false, + HeaderJson: headerJson, + PayloadJson: payloadJson, + Claims: claims); + } + } + catch + { + // Fall through to opaque token display + } + + return new TokenDisplayResult( + RawToken: accessToken, + IsOpaqueToken: true, + HeaderJson: null, + PayloadJson: null, + Claims: []); + } + + private static string PrettyPrint(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true }); + } + catch + { + return json; + } + } +} + +public record SpaceLink(string Name, string Url); + +/// +/// Holds the decoded token display data for rendering in the view. +/// +public record TokenDisplayResult( + string RawToken, + bool IsOpaqueToken, + string? HeaderJson, + string? PayloadJson, + IReadOnlyList Claims); + +/// +/// A single claim from a decoded JWT. +/// +public record TokenClaim(string Type, string Value); diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Shared/_Layout.cshtml b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Shared/_Layout.cshtml new file mode 100644 index 00000000..97b8c48a --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Shared/_Layout.cshtml @@ -0,0 +1,27 @@ + + + + + + @ViewData["Title"] — MultiSpace Demo + + + + + +
+ @RenderBody() +
+ +
+

Duende MultiSpace — local demo only. Do not use in production.

+
+ + + @await RenderSectionAsync("Scripts", required: false) + + diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Shared/_ValidationSummary.cshtml b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Shared/_ValidationSummary.cshtml new file mode 100644 index 00000000..fa6bd037 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/Shared/_ValidationSummary.cshtml @@ -0,0 +1,12 @@ +@* Renders model-level validation errors when ModelState is invalid. *@ +@if (!ViewData.ModelState.IsValid) +{ + +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/_ViewImports.cshtml b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/_ViewImports.cshtml new file mode 100644 index 00000000..b5a266c4 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using MultiSpace +@using MultiSpace.Pages +@using MultiSpace.Services +@namespace MultiSpace.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/_ViewStart.cshtml b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/_ViewStart.cshtml new file mode 100644 index 00000000..820a2f6e --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Program.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Program.cs new file mode 100644 index 00000000..380a9fb2 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Program.cs @@ -0,0 +1,83 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Duende.IdentityServer; +using Duende.IdentityServer.UserManagement; +using Duende.IdentityServer.Validation; +using Duende.MultiSpace; +using Duende.Storage.Schema; +using Duende.Storage.Sqlite; +using Duende.UserManagement; +using MultiSpace.Seed; +using MultiSpace.Services; + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.SetMinimumLevel(LogLevel.Debug); + +builder.Services.AddRazorPages(); +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddHttpClient("TokenRequest") + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + +builder.Services.AddMultiSpace(); +builder.Services.Configure(o => o.FallbackToDefault = true); + +builder.Services + .AddIdentityServer(options => + { + options.UserInteraction.LoginUrl = "/Account/Login"; + options.UserInteraction.LogoutUrl = "/Account/Logout"; + options.Events.RaiseErrorEvents = true; + options.Events.RaiseInformationEvents = true; + options.Events.RaiseFailureEvents = true; + options.Events.RaiseSuccessEvents = true; + }) + .AddUserManagement(um => + { + um.Authentication(x => { }); + + um.AddSqliteInMemoryStore(); + }) + .AddProfileService() + .AddConfigurationStorage(); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); + + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} + +using (var scope = app.Services.CreateScope()) +{ + var sp = scope.ServiceProvider; + + await sp.GetRequiredService().MigrateAsync(CancellationToken.None); + + await new SeedData(sp).Seed(CancellationToken.None); +} + +app.UseHttpsRedirection(); + +// Space resolution must run before IdentityServer / auth so that the correct +// pool context is established before any IS middleware reads from storage. +app.UseMultiSpaceResolution(); +app.UseStaticFiles(); +app.UseRouting(); + +app.UseIdentityServer(); +app.UseAuthorization(); + +app.MapRazorPages(); +app.MapUserManagement(); + +app.Run(); + diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Properties/launchSettings.json b/IdentityServer/v8/MultiSpace/src/MultiSpace/Properties/launchSettings.json new file mode 100644 index 00000000..334ef42c --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "https://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/DemoCredentials.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/DemoCredentials.cs new file mode 100644 index 00000000..8b305a72 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/DemoCredentials.cs @@ -0,0 +1,30 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace MultiSpace.Seed; + +/// +/// Single source of truth for demo credentials seeded per space. +/// Used by SeedData to create users and by page models to display credentials. +/// +public static class DemoCredentials +{ + /// Demo password shared across all seeded users. + public const string Password = "Pa$$Word123"; + + /// + /// Returns the demo username for a given space, or null if no demo user is seeded for that space. + /// + public static string? GetUsernameForSpace(string spaceName) + { + // Non-default space IDs encode the space name + var normalizedSpaceName = spaceName.ToLowerInvariant(); + return normalizedSpaceName switch + { + "default" => "default-user", + "space1" => "space1-user", + "space2" => "space2-user", + _ => null + }; + } +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/DemoUserAttributes.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/DemoUserAttributes.cs new file mode 100644 index 00000000..6300a21c --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/DemoUserAttributes.cs @@ -0,0 +1,29 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Duende.Storage.EntityAttributeValue; + +namespace MultiSpace.Seed; + +/// +/// Set of example attributes that are used during authentication +/// +public class DemoUserAttributes +{ + public static readonly AttributeDefinition UserName = new() + { + Code = AttributeCode.Create("UserName"), + AttributeType = new ScalarAttributeType(ScalarDataType.String), + Description = AttributeDescription.Create("The Username to be used in username / password auth. "), + // Note, username has to be a unique property to be able to use it + // for username / password authentication. + IsUnique = true + }; + + public static readonly AttributeDefinition Email = new() + { + Code = AttributeCode.Create("Email"), + AttributeType = new ScalarAttributeType(ScalarDataType.String), + Description = AttributeDescription.Create("The Email of the user") + }; +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/SeedData.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/SeedData.cs new file mode 100644 index 00000000..db68ed07 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/SeedData.cs @@ -0,0 +1,216 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Duende.IdentityServer.Admin; +using Duende.IdentityServer.Admin.ApiScopes; +using Duende.IdentityServer.Admin.Clients; +using Duende.IdentityServer.Admin.IdentityProviders; +using Duende.IdentityServer.Models; +using Duende.MultiSpace; +using Duende.Storage.EntityAttributeValue; +using Duende.UserManagement; +using Duende.UserManagement.Authentication; +using Duende.UserManagement.Profiles; + +namespace MultiSpace.Seed; + +public class SeedData(IServiceProvider services) +{ + ISpaceAdmin spaceAdmin = services.GetRequiredService(); + + public async Task Seed(CancellationToken ct) + { + var space1 = await CreateSpace("space1", matchOrigin: "https://space1.dev.localhost:5000", ct: ct); + var space2 = await CreateSpace("space2", matchOrigin: "https://space2.dev.localhost:5000", ct: ct); + var space3 = await CreateSpace("space3", matchPath: "/space3", ct: ct); + + SpaceId[] spaces = [SpaceId.Default, space1, space2, space3]; + + foreach (var space in spaces) + { + await SeedUserSchema(space, ct); + } + + // Seed users in each space. Note, space3 doesn't have a user. + await CreateUser(SpaceId.Default, DemoCredentials.GetUsernameForSpace("default")!, ct); + await CreateUser(space1, DemoCredentials.GetUsernameForSpace("space1")!, ct); + await CreateUser(space2, DemoCredentials.GetUsernameForSpace("space2")!, ct); + + // All user data in each space is completely isolated from each other. + // So, the same username can be used in each space. + foreach (var space in spaces) + { + await CreateUser(space, "example-user", ct); + } + + // Create an idp for each space that points to demo.duendesoftware.com + await CreateIdp(SpaceId.Default, "default-idp", ct); + await CreateIdp(space1, "space1-idp", ct); + await CreateIdp(space2, "space2-idp", ct); + await CreateIdp(space3, "space3-idp", ct); + + // Create a unique client in each space. + // API scopes must be created once per space before any clients reference them. + foreach (var space in spaces) + { + await CreateApiScope(space, "scope1", ct); + } + + await CreateClient(SpaceId.Default, "default-client", ct); + await CreateClient(space1, "space1-client", ct); + await CreateClient(space2, "space2-client", ct); + await CreateClient(space3, "space3-client", ct); + + // All configuration data in each space is completely isolated. So if we want + // the same client in all spaces, we have to explicitly create it. + foreach (var space in spaces) + { + await CreateClient(space, "example-client", ct); + } + } + + private async Task SeedUserSchema(SpaceId spaceId, CancellationToken ct) + { + var spacedService = services.GetServiceForSpace(spaceId); + var result = await spacedService.Service.TryAddAttributeDefinitionAsync(DemoUserAttributes.UserName, ct); + if (!result) + { + throw new InvalidOperationException("Failed to create an user profile schema for space " + spaceId); + } + + result = await spacedService.Service.TryAddAttributeDefinitionAsync(DemoUserAttributes.Email, ct); + if (!result) + { + throw new InvalidOperationException("Failed to create an user profile schema for space " + spaceId); + } + } + + private async Task CreateIdp(SpaceId spaceId, string name, CancellationToken ct) + { + var spacedService = services.GetServiceForSpace(spaceId); + + var saveResult = await spacedService.Service.CreateAsync(new IdentityProviderConfiguration() + { + Scheme = "oidc", + Type = "oidc", + Enabled = true, + DisplayName = name, + Properties = new Dictionary() + { + ["Authority"] = "https://demo.duendesoftware.com", + ["ClientId"] = "interactive.confidential", + ["ClientSecret"] = "secret", + ["ResponseType"] = "code", + ["GetClaimsFromUserInfoEndpoint"] = "true" + } + }, ct); + + if (!saveResult.IsSuccess) + { + throw new InvalidOperationException($"Failed to save identity provider. {saveResult}"); + } + } + + private async Task CreateSpace(string name, string? matchOrigin = null, PathString? matchPath = null, + CancellationToken ct = default) + { + var saveResult = await spaceAdmin.CreateAsync(new CreateSpaceConfiguration + { + Name = name, + MatchPatterns = + [ + new SpaceMatchPattern() + { + Origin = matchOrigin, + Path = matchPath?.ToString() + } + ], + }, ct); + + if (!saveResult.IsSuccess) + { + throw new InvalidOperationException($"Failed to save space {name}"); + } + + return saveResult.Id; + } + + private async Task CreateUser(SpaceId spaceId, string userName, CancellationToken ct) + { + using var spacedUserAdmin = services.GetServiceForSpace(spaceId); + var profileAdmin = spacedUserAdmin.Service.Profiles; + + // First, we'll need to create a profile for the user. It has a unique username. + var attributes = new AttributeValueCollection(await profileAdmin.GetSchemaAsync(ct)); + attributes.Set(DemoUserAttributes.UserName, userName); // This username is used for username / password login. + var userSubjectId = UserSubjectId.New(); + var saved = await profileAdmin.TryAddAsync(userSubjectId, attributes.Validate(), ct); + + if (saved == null) + { + throw new InvalidOperationException("Failed to save user"); + } + + // Then we have to setup password authentication. To do this, we first have to add an (empty) authenticator. + await spacedUserAdmin.Service.Authenticators.TryAddAsync(userSubjectId, [], [], ct); + + // Then we can set the password on the user. To do this, we have to create + // a validated password (to see if it matches the password policies) + using var spacedAuthenticatorSelfService = services.GetServiceForSpace(spaceId); + var validatedPassword = await spacedAuthenticatorSelfService.Service.ValidatePasswordAsync(userSubjectId, DemoCredentials.Password, ct); + + if (validatedPassword == null) + { + throw new InvalidOperationException("password doesn't comply with the password policy"); + } + + var setPasswordResult = await spacedAuthenticatorSelfService.Service.TrySetPasswordAsync(userSubjectId, validatedPassword, ct); + + if (!setPasswordResult) + { + throw new InvalidOperationException($"Failed to set password. {setPasswordResult}"); + } + } + + private async Task CreateApiScope(SpaceId space, string scopeName, CancellationToken ct) + { + using var scopeAdmin = services.GetServiceForSpace(space); + + var saveResult = await scopeAdmin.Service.CreateAsync(new ApiScopeConfiguration() + { + Name = scopeName, + Enabled = true + }, ct); + + if (!saveResult.IsSuccess) + { + throw new InvalidOperationException($"Failed to save apiScope '{scopeName}' in space {space}"); + } + } + + private async Task CreateClient(SpaceId space, string clientId, CancellationToken ct) + { + using var clientAdmin = services.GetServiceForSpace(space); + + var saveResult = await clientAdmin.Service.CreateAsync( + new CreateClient() + { + ClientId = clientId, + AllowedGrantTypes = [GrantType.ClientCredentials], + AllowedScopes = ["scope1"], + ClientSecrets = + [ + new CreateClientSecret() + { + PlaintextValue = "secret", + HashAlgorithm = SecretHashAlgorithm.Sha256 + } + ] + }, ct); + + if (!saveResult.IsSuccess) + { + throw new InvalidOperationException("Failed to save client"); + } + } +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/ServiceProviderExtensions.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/ServiceProviderExtensions.cs new file mode 100644 index 00000000..622cc1cf --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/ServiceProviderExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Duende.MultiSpace; + +namespace MultiSpace.Seed; + +public static class ServiceProviderExtensions +{ + extension(IServiceProvider sp) + { + /// + /// To explicitly set or override the space, you have to do this on a scoped service provider. + /// This method will create a wrapper around such a scope for convenience. + /// The scope will be disposed when the SpacedService is disposed. + /// + /// The type to resolve with a given SpaceID associated with it. + /// The space id to set. + /// The service that's configured to use a specific space. + public SpacedService GetServiceForSpace(SpaceId spaceId) where T : notnull + { + var scope = sp.CreateScope(); + var spacedServices = scope.ServiceProvider; + spacedServices.GetRequiredService().SetSpace(spaceId); + return new SpacedService(scope, spacedServices.GetRequiredService()); + } + + } +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/SpacedService.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/SpacedService.cs new file mode 100644 index 00000000..92a5056f --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Seed/SpacedService.cs @@ -0,0 +1,17 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace MultiSpace.Seed; + +/// +/// A convenience wrapper around a service that is configured to use a specific space. +/// Disposing this instance will dispose the underlying scope that was created to resolve the service. +/// +/// +/// +/// +public class SpacedService(IServiceScope scope, T service) : IDisposable +{ + public T Service => service; + public void Dispose() => scope.Dispose(); +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Services/AddSpaceNameToClaimsRequestValidator.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Services/AddSpaceNameToClaimsRequestValidator.cs new file mode 100644 index 00000000..734b05a0 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Services/AddSpaceNameToClaimsRequestValidator.cs @@ -0,0 +1,24 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Security.Claims; +using Duende.IdentityServer.Validation; +using Duende.MultiSpace; + +namespace MultiSpace.Services; + +public class AddSpaceNameToClaimsRequestValidator(ISpaceContextAccessor spaceContextAccessor, ISpaceStore spaceStore) + : ICustomTokenRequestValidator +{ + public async Task ValidateAsync(CustomTokenRequestValidationContext context, CancellationToken ct) + { + var request = context.Result?.ValidatedRequest; + if (request == null) + { + return; + } + + var space = await spaceStore.TryGetSpace(spaceContextAccessor.GetSpaceId(), ct); + request.ClientClaims.Add(new Claim("space", space?.Name ?? "default")); + } +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/Services/SpaceClaimAugmentationProfileService.cs b/IdentityServer/v8/MultiSpace/src/MultiSpace/Services/SpaceClaimAugmentationProfileService.cs new file mode 100644 index 00000000..39a55565 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/Services/SpaceClaimAugmentationProfileService.cs @@ -0,0 +1,55 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Duende.IdentityServer; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.UserManagement; + +namespace MultiSpace.Services; + +public sealed class SpaceClaimAugmentationProfileService(UserManagementProfileService inner) : IProfileService +{ + // Claim type constant + // "space_id" is a custom claim type. In a production deployment you would + // register it as a UserClaim on the relevant IdentityResource or ApiScope so + // that OIDC clients can request it by name. For this demo sample we emit it + // unconditionally (see comment on GetProfileDataAsync below). + public const string SpaceIdClaimType = "space"; + + // The UserManagement UserManagementProfileService already handles standard OIDC claims + // (email, name, sub, etc.) for UserManagement-backed users. We delegate to + // it first so all standard claims are populated, then we add our space_id. + // + // We depend on the concrete UserManagementProfileService rather than IProfileService + // to avoid a circular dependency: AddProfileService + // replaces the IProfileService registration, so resolving IProfileService here + // would result in infinite recursion. Using the concrete type bypasses the + // decorator chain safely. + + /// + public async Task GetProfileDataAsync( + ProfileDataRequestContext context, + CancellationToken cancellationToken) + { + // Delegate to the wrapped service (e.g. UserManagement's ProfileService) + // so standard OIDC claims are populated first. + await inner.GetProfileDataAsync(context, cancellationToken); + + // Forward the space_id claim that was embedded into the session cookie at + // login time (see Pages/Account/Login/Index.cshtml.cs). + // We emit it unconditionally (not gated on RequestedClaimTypes) so that + // the API resource receiving access_tokens always has the space context + // without requiring per-client claim-request configuration. + var spaceIdClaim = context.Subject.FindFirst(SpaceIdClaimType); + if (spaceIdClaim is not null && + !context.IssuedClaims.Any(c => c.Type == SpaceIdClaimType)) + { + context.IssuedClaims.Add(spaceIdClaim); + } + } + + /// + public Task IsActiveAsync(IsActiveContext context, CancellationToken cancellationToken) + => inner.IsActiveAsync(context, cancellationToken); +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/appsettings.Development.json b/IdentityServer/v8/MultiSpace/src/MultiSpace/appsettings.Development.json new file mode 100644 index 00000000..aa9e4b03 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/appsettings.Development.json @@ -0,0 +1,18 @@ +{ + "DetailedErrors": true, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Duende.MultiSpace": "Debug" + } + }, + + // ⚠ LOCAL DEVELOPMENT DEFAULTS — demo credentials for the sample only. + // Never copy these values to a production or staging environment. + "Demo": { + "Password": "Pa$$Word123", + "ClientSecret": "secret", + "ShowCredentials": true + } +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/appsettings.json b/IdentityServer/v8/MultiSpace/src/MultiSpace/appsettings.json new file mode 100644 index 00000000..b2df8b40 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/appsettings.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + // Restrict to the four known space hosts. Override in appsettings.Development.json + // or via environment variable if you run on a different host/port. + "AllowedHosts": "localhost;space1.dev.localhost;space2.dev.localhost;ignored.dev.localhost", + + // Demo sample configuration — overridable per environment. + // ⚠ These values are LOCAL-DEVELOPMENT DEFAULTS ONLY. + // Override Demo:Password and Demo:ClientSecret with strong secrets before + // any non-local deployment. + "Demo": { + // Default intentionally blank so production starts without seeded users + // unless explicitly configured. Set in appsettings.Development.json. + "Password": "", + "ClientSecret": "", + // ShowCredentials: false by default — credentials card is never rendered + // in production. Set to true ONLY in Development (appsettings.Development.json). + "ShowCredentials": false + } +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/wwwroot/css/site.css b/IdentityServer/v8/MultiSpace/src/MultiSpace/wwwroot/css/site.css new file mode 100644 index 00000000..405674d8 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/wwwroot/css/site.css @@ -0,0 +1,521 @@ +/* MultiSpace Demo — site.css + Minimal, dependency-free stylesheet. No client-side frameworks required. */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 16px; + line-height: 1.6; + color: #334155; + background: #f1f5f9; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ── Layout ─────────────────────────────────────────────────────────── */ + +.site-header { + background: #1e293b; + color: #e2e8f0; + padding: 0.75rem 1.5rem; +} + +.site-nav { + display: flex; + align-items: center; + gap: 1rem; +} + +.site-title { + font-weight: 700; + font-size: 1.1rem; + letter-spacing: 0.02em; +} + +.site-main { + flex: 1; + padding: 2rem 1rem; + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +.site-footer { + background: #1e293b; + color: #94a3b8; + font-size: 0.8rem; + padding: 0.75rem 1.5rem; + text-align: center; +} + +/* ── Cards ──────────────────────────────────────────────────────────── */ + +.space-home { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 2rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03); +} + +.card h2 { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1rem; + color: #1e293b; + border-bottom: 1px solid #e2e8f0; + padding-bottom: 0.75rem; +} + +.card h3 { + font-size: 1rem; + margin: 1rem 0 0.5rem; + color: #374151; +} + +.card h4 { + font-size: 0.9rem; + margin: 1rem 0 0.3rem; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ── Space identity ─────────────────────────────────────────────────── */ + +.space-info h1 { + font-size: 1.4rem; + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.badge { + background: #4f46e5; + color: #fff; + font-size: 0.75rem; + font-weight: 600; + padding: 0.2em 0.6em; + border-radius: 999px; + vertical-align: middle; +} + +.space-id { + margin-top: 0.4rem; + font-size: 0.85rem; + color: #6b7280; +} + +.explainer { + margin-top: 0.75rem; + font-size: 0.9rem; +} + +.explainer ul { + margin-top: 0.4rem; + padding-left: 1.25rem; +} + +.explainer li { + margin-bottom: 0.2rem; +} + +/* ── Cross-space links ──────────────────────────────────────────────── */ + +.space-links { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.space-links li { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + border-radius: 4px; + font-size: 0.95rem; +} + +.space-links li.current-space { + background: #eff6ff; + border-left: 3px solid #4f46e5; +} + +.current-label { + font-size: 0.8rem; + color: #6b7280; +} + +.link-url { + font-size: 0.8rem; + color: #9ca3af; +} + +/* ── Demo credentials ───────────────────────────────────────────────── */ + +.demo-warning { + font-size: 0.875rem; + color: #92400e; + background: #fef3c7; + border: 1px solid #fde68a; + border-radius: 8px; + padding: 0.875rem 1rem; + margin-bottom: 1.25rem; + line-height: 1.5; +} + +.demo-credentials { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1.25rem; +} + +.demo-credentials-title { + font-size: 0.875rem; + font-weight: 600; + color: #475569; + margin-bottom: 0.75rem; +} + +.demo-credentials-hint { + margin-top: 0.75rem; + color: #64748b; + font-size: 0.8125rem; + line-height: 1.5; +} + +.demo-credentials table { + border-collapse: collapse; + font-size: 0.875rem; + width: 100%; +} + +.demo-credentials th { + text-align: left; + padding: 0.5rem 1rem 0.5rem 0; + color: #64748b; + font-weight: 500; + white-space: nowrap; + width: 100px; +} + +.demo-credentials td { + padding: 0.5rem 0; +} + +.demo-credentials code { + background: #ffffff; + border: 1px solid #e2e8f0; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +/* ── Auth area ──────────────────────────────────────────────────────── */ + +.auth-area p { + margin-bottom: 0.75rem; + font-size: 0.95rem; +} + +/* ── Login form ─────────────────────────────────────────────────────── */ + +.login-card { + max-width: 440px; + margin: 0 auto; +} + +.form-field { + margin-bottom: 1.25rem; +} + +.form-field label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #334155; + margin-bottom: 0.5rem; +} + +.form-field input[type="text"], +.form-field input[type="password"] { + width: 100%; + padding: 0.625rem 0.875rem; + font-size: 0.9375rem; + color: #1e293b; + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 6px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.form-field input[type="text"]:focus, +.form-field input[type="password"]:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.login-error { + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 0.875rem 1rem; + margin-bottom: 1.25rem; + color: #991b1b; + font-size: 0.875rem; + line-height: 1.5; +} + +.external-login-section { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #e2e8f0; +} + +.external-login-section form { + margin-bottom: 0.75rem; +} + +.external-login-section form:last-child { + margin-bottom: 0; +} + +.btn-external { + background: #64748b; +} + +.btn-external:hover { + background: #475569; +} + +/* ── Button ─────────────────────────────────────────────────────────── */ + +.btn { + display: block; + width: 100%; + background: #6366f1; + color: #fff; + font-size: 0.9375rem; + font-weight: 600; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + cursor: pointer; + text-decoration: none; + text-align: center; + transition: background 0.15s ease; +} + +.btn:hover { + background: #4f46e5; +} + +.btn-secondary { + background: #e2e8f0; + color: #334155; +} + +.btn-secondary:hover { + background: #cbd5e1; + text-decoration: none; +} + +/* ── Logout actions ─────────────────────────────────────────────────── */ + +.logout-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.logout-actions .btn { + display: inline-block; + width: auto; + flex: 1 1 auto; + min-width: 140px; +} + +/* ── Claims table ───────────────────────────────────────────────────── */ + +.claims-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.claims-table th, +.claims-table td { + text-align: left; + padding: 0.35rem 0.6rem; + border-bottom: 1px solid #f1f5f9; + word-break: break-all; +} + +.claims-table thead th { + background: #f8fafc; + font-weight: 600; + color: #374151; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.claims-table tbody tr:hover { + background: #f8fafc; +} + +/* ── Token output ───────────────────────────────────────────────────── */ + +.token-request form { + margin: 1rem 0; +} + +/* ── Client selector ────────────────────────────────────────────────── */ + +.client-selector { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1.25rem; + background: #f8fafc; +} + +.client-selector legend { + font-size: 0.8rem; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0 0.5rem; +} + +.client-selector label { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + margin-bottom: 0.25rem; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; + transition: background 0.1s ease; +} + +.client-selector label:hover { + background: #e2e8f0; +} + +.client-selector label:last-child { + margin-bottom: 0; +} + +.client-selector input[type="radio"] { + width: 1rem; + height: 1rem; + accent-color: #6366f1; + flex-shrink: 0; +} + +.client-selector .note { + font-size: 0.8rem; + color: #64748b; + font-style: italic; +} + +.token-error { + margin-top: 1rem; + padding: 0.75rem 1rem; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + color: #991b1b; + font-size: 0.9rem; +} + +.token-output { + margin-top: 1rem; + border-top: 1px solid #e2e8f0; + padding-top: 1rem; +} + +.token-meta { + font-size: 0.85rem; + color: #6b7280; + margin-bottom: 0.5rem; +} + +.token-raw-wrap { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 5px; + padding: 0.75rem; + overflow-x: auto; + word-break: break-all; +} + +.token-raw-value { + font-size: 0.75rem; + color: #374151; + font-family: "Cascadia Code", "Fira Code", Consolas, monospace; +} + +.token-json { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 5px; + padding: 0.75rem; + font-size: 0.8rem; + font-family: "Cascadia Code", "Fira Code", Consolas, monospace; + overflow-x: auto; + white-space: pre; + color: #1e3a5f; +} + +.token-raw { + font-size: 0.75rem; + margin-top: 0.5rem; + white-space: pre-wrap; + word-break: break-all; +} + +.token-opaque-note { + margin-top: 0.75rem; + padding: 0.6rem 0.9rem; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 5px; + font-size: 0.85rem; + color: #92400e; +} + +code { + font-family: "Cascadia Code", "Fira Code", Consolas, monospace; + font-size: 0.9em; + background: #f1f5f9; + padding: 0.1em 0.3em; + border-radius: 3px; +} + +a { + color: #4f46e5; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} diff --git a/IdentityServer/v8/MultiSpace/src/MultiSpace/wwwroot/js/site.js b/IdentityServer/v8/MultiSpace/src/MultiSpace/wwwroot/js/site.js new file mode 100644 index 00000000..02072000 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/src/MultiSpace/wwwroot/js/site.js @@ -0,0 +1,46 @@ +// MultiSpace Demo — site.js +// Minimal vanilla JS. No frameworks required. +// Currently provides only cosmetic/accessibility helpers. + +(function () { + "use strict"; + + // ── Copy-to-clipboard for raw token ────────────────────────────────────── + // Wraps any element with class "token-raw-value" in a container that shows + // a lightweight copy button. Gracefully skipped if the Clipboard API is + // unavailable. + function addCopyButtons() { + if (!navigator.clipboard) return; + + document.querySelectorAll(".token-raw-value").forEach(function (el) { + var wrap = el.closest(".token-raw-wrap"); + if (!wrap || wrap.dataset.copyAdded) return; + wrap.dataset.copyAdded = "1"; + wrap.style.position = "relative"; + + var btn = document.createElement("button"); + btn.textContent = "Copy"; + btn.className = "copy-btn"; + btn.style.cssText = + "position:absolute;top:6px;right:6px;font-size:0.72rem;" + + "padding:2px 8px;border:1px solid #d1d5db;border-radius:4px;" + + "background:#fff;cursor:pointer;color:#374151;"; + + btn.addEventListener("click", function () { + navigator.clipboard.writeText(el.textContent).then(function () { + btn.textContent = "Copied!"; + setTimeout(function () { btn.textContent = "Copy"; }, 1500); + }); + }); + + wrap.appendChild(btn); + }); + } + + // Run after DOM is ready. + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", addCopyButtons); + } else { + addCopyButtons(); + } +})(); diff --git a/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/AssemblyInfo.cs b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/AssemblyInfo.cs new file mode 100644 index 00000000..9ac56096 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using MultiSpace.PlaywrightTests.Infrastructure; +using Xunit; + +[assembly: AssemblyFixture(typeof(MultiSpaceFixture))] diff --git a/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Infrastructure/BrowserContextFactory.cs b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Infrastructure/BrowserContextFactory.cs new file mode 100644 index 00000000..2f9ace49 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Infrastructure/BrowserContextFactory.cs @@ -0,0 +1,48 @@ +using System.Runtime.CompilerServices; +using Microsoft.Playwright; + +namespace MultiSpace.PlaywrightTests.Infrastructure; + +public static class BrowserContextFactory +{ + public static async Task CreateAsync([CallerMemberName] string caller = "") + { + var playwright = await Playwright.CreateAsync(); + var headless = Environment.GetEnvironmentVariable("CI") is not null; + var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = headless + }); + var context = await browser.NewContextAsync(new BrowserNewContextOptions + { + IgnoreHTTPSErrors = true + }); + await context.Tracing.StartAsync(new TracingStartOptions + { + Screenshots = true, + Snapshots = true, + Sources = true + }); + return new TracedBrowserContext(playwright, browser, context, caller); + } +} + +public sealed class TracedBrowserContext( + IPlaywright playwright, + IBrowser browser, + IBrowserContext context, + string caller) + : IAsyncDisposable +{ + public IBrowserContext Context { get; } = context; + + public async ValueTask DisposeAsync() + { + var dir = Path.Combine("artifacts", "playwright-traces"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, $"{caller}-{Guid.NewGuid():N}.zip"); + await Context.Tracing.StopAsync(new TracingStopOptions { Path = path }); + await browser.CloseAsync(); + playwright.Dispose(); + } +} diff --git a/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Infrastructure/MultiSpaceFixture.cs b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Infrastructure/MultiSpaceFixture.cs new file mode 100644 index 00000000..f3f1c920 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Infrastructure/MultiSpaceFixture.cs @@ -0,0 +1,28 @@ +using Aspire.Hosting; +using Aspire.Hosting.Testing; +using Xunit; + +namespace MultiSpace.PlaywrightTests.Infrastructure; + +public class MultiSpaceFixture : IAsyncLifetime +{ + private DistributedApplication? _app; + + public string BaseUrl => "https://localhost:5000"; + + public async ValueTask InitializeAsync() + { + var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + _app = await builder.BuildAsync(); + await _app.StartAsync(); + await _app.ResourceNotifications.WaitForResourceHealthyAsync("multispace"); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + } +} diff --git a/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/MultiSpace.PlaywrightTests.csproj b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/MultiSpace.PlaywrightTests.csproj new file mode 100644 index 00000000..1f508e38 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/MultiSpace.PlaywrightTests.csproj @@ -0,0 +1,20 @@ + + + + Exe + true + net10.0 + + + + + + + + + + + + + + diff --git a/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Tests/ClientCredentialsTests.cs b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Tests/ClientCredentialsTests.cs new file mode 100644 index 00000000..9d43d5d3 --- /dev/null +++ b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Tests/ClientCredentialsTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Playwright; +using MultiSpace.PlaywrightTests.Infrastructure; +using Xunit; + +namespace MultiSpace.PlaywrightTests.Tests; + +public class ClientCredentialsTests(MultiSpaceFixture fixture) +{ + [Fact] + public async Task Can_get_token_for_default_space() + { + await using var traced = await BrowserContextFactory.CreateAsync(); + var page = await traced.Context.NewPageAsync(); + + await page.GotoAsync(fixture.BaseUrl); + await page.CheckAsync("input[name=SelectedClient][value=space]"); + await page.ClickAsync("button:has-text('Request Token')"); + await page.WaitForLoadStateAsync(); + + await Assertions.Expect(page.Locator(".token-output")).ToBeVisibleAsync(); + var payload = await page.Locator(".token-json").Last.TextContentAsync(); + Assert.Contains("\"client_id\": \"default-client\"", payload); + Assert.Contains("\"client_space\": \"default\"", payload); + } + + [Fact] + public async Task Can_get_token_for_space1() + { + await using var traced = await BrowserContextFactory.CreateAsync(); + var page = await traced.Context.NewPageAsync(); + + // Navigate to space1 via link from home + await page.GotoAsync(fixture.BaseUrl); + await page.ClickAsync("a:has-text('space1.dev.localhost')"); + await page.WaitForLoadStateAsync(); + + // Request token using the space client + await page.CheckAsync("input[name=SelectedClient][value=space]"); + await page.ClickAsync("button:has-text('Request Token')"); + await page.WaitForLoadStateAsync(); + + await Assertions.Expect(page.Locator(".token-output")).ToBeVisibleAsync(); + var payload = await page.Locator(".token-json").Last.TextContentAsync(); + Assert.Contains("\"client_id\": \"space1-client\"", payload); + Assert.Contains("\"client_space\": \"space1\"", payload); + } + + [Fact] + public async Task Can_get_token_for_space3() + { + await using var traced = await BrowserContextFactory.CreateAsync(); + var page = await traced.Context.NewPageAsync(); + + // Navigate to space3 via link from home + await page.GotoAsync(fixture.BaseUrl); + await page.ClickAsync("a:has-text('/space3')"); + await page.WaitForLoadStateAsync(); + + // Request token using the space client + await page.CheckAsync("input[name=SelectedClient][value=space]"); + await page.ClickAsync("button:has-text('Request Token')"); + await page.WaitForLoadStateAsync(); + + await Assertions.Expect(page.Locator(".token-output")).ToBeVisibleAsync(); + var payload = await page.Locator(".token-json").Last.TextContentAsync(); + Assert.Contains("\"client_id\": \"space3-client\"", payload); + Assert.Contains("\"client_space\": \"space3\"", payload); + } +} diff --git a/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Tests/LoginTests.cs b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Tests/LoginTests.cs new file mode 100644 index 00000000..663c664e --- /dev/null +++ b/IdentityServer/v8/MultiSpace/tests/MultiSpace.PlaywrightTests/Tests/LoginTests.cs @@ -0,0 +1,38 @@ +using MultiSpace.PlaywrightTests.Infrastructure; +using Xunit; + +namespace MultiSpace.PlaywrightTests.Tests; + +public class LoginTests(MultiSpaceFixture fixture) +{ + [Fact] + public async Task Can_login_to_default_space() + { + await using var traced = await BrowserContextFactory.CreateAsync(); + var page = await traced.Context.NewPageAsync(); + + await page.GotoAsync(fixture.BaseUrl); + await page.ClickAsync("a:has-text('Sign in')"); + await page.FillAsync("[name=Username]", "default-user"); + await page.FillAsync("[name=Password]", "Pa$$Word123"); + await page.ClickAsync("button[type=submit]"); + + await page.WaitForURLAsync(url => !url.Contains("/Account/Login")); + } + + [Fact] + public async Task Can_login_to_space1_via_hostname() + { + await using var traced = await BrowserContextFactory.CreateAsync(); + var page = await traced.Context.NewPageAsync(); + + await page.GotoAsync(fixture.BaseUrl); + await page.ClickAsync("a:has-text('space1.dev.localhost')"); + await page.ClickAsync("a:has-text('Sign in')"); + await page.FillAsync("[name=Username]", "space1-user"); + await page.FillAsync("[name=Password]", "Pa$$Word123"); + await page.ClickAsync("button[type=submit]"); + + await page.WaitForURLAsync(url => !url.Contains("/Account/Login")); + } +} diff --git a/samples.slnx b/samples.slnx index 98da2fd8..849b431e 100644 --- a/samples.slnx +++ b/samples.slnx @@ -662,6 +662,16 @@ + + + + + + + + + +