diff --git a/IdentityServer/v8/Spaces/Directory.Packages.props b/IdentityServer/v8/Spaces/Directory.Packages.props index abbdbcd4..c7d4781b 100644 --- a/IdentityServer/v8/Spaces/Directory.Packages.props +++ b/IdentityServer/v8/Spaces/Directory.Packages.props @@ -4,11 +4,11 @@ true - - - - - + + + + + diff --git a/IdentityServer/v8/Spaces/README.md b/IdentityServer/v8/Spaces/README.md index 7115af12..11e87faf 100644 --- a/IdentityServer/v8/Spaces/README.md +++ b/IdentityServer/v8/Spaces/README.md @@ -60,7 +60,7 @@ Since origin based routing takes precedence, navigating to https://space1.dev.lo ## Demo Credentials -All passwords are `Pa$$Word123`. +All passwords are `Pa$$Word123456789`. | Space | Space-Specific User | Shared User | |-------|-------------------|-------------| diff --git a/IdentityServer/v8/Spaces/src/Spaces/Pages/Account/ExternalLoginCallback.cshtml.cs b/IdentityServer/v8/Spaces/src/Spaces/Pages/Account/ExternalLoginCallback.cshtml.cs index d81ee4ff..ecf83d85 100644 --- a/IdentityServer/v8/Spaces/src/Spaces/Pages/Account/ExternalLoginCallback.cshtml.cs +++ b/IdentityServer/v8/Spaces/src/Spaces/Pages/Account/ExternalLoginCallback.cshtml.cs @@ -67,16 +67,16 @@ public async Task OnGetAsync(string? returnUrl) ?? principal.FindFirst(ClaimTypes.Name)?.Value ?? "name"; + // The built-in user profile schema requires a unique email. Fall back to a + // per-user placeholder when the external provider doesn't supply one. var email = principal.FindFirst(JwtClaimTypes.Email)?.Value - ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "test@test.nl"; + ?? principal.FindFirst(ClaimTypes.Email)?.Value + ?? $"{userId}@example.invalid"; 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); - } + attributes.Set(OidcStandardAttributes.Email, email); var saved = await profileSelfService.TryCreateAsync(userId, attributes.Validate(), ct); if (saved is null) diff --git a/IdentityServer/v8/Spaces/src/Spaces/Program.cs b/IdentityServer/v8/Spaces/src/Spaces/Program.cs index f08af109..f9c06f4f 100644 --- a/IdentityServer/v8/Spaces/src/Spaces/Program.cs +++ b/IdentityServer/v8/Spaces/src/Spaces/Program.cs @@ -38,19 +38,17 @@ options.Events.RaiseFailureEvents = true; options.Events.RaiseSuccessEvents = true; }) + .AddStorage(x => x.AddSqliteInMemory()) + .AddConfigurationStorage() + .AddOperationalStorage() .AddUserManagement(um => { um.Authentication(x => { }); - - um.AddSqliteInMemoryStore(); }) .AddProfileService() - .AddStorage(x => x.AddSqliteInMemoryStore()) - .AddInMemoryDataExtensionSchemas([new SchemaConfiguration() - { - SchemaId = SchemaId.UserProfile, - AttributeDefinitions = [DemoUserAttributes.Email, DemoUserAttributes.UserName] - }]); + // The built-in user profile schema already contains email, name, given_name and + // family_name. Extend it with the unique username used for password login. + .AddInMemoryDataExtensionSchemas([BuiltInSchemas.UserProfile.Extend(DemoUserAttributes.UserName)]); builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -67,7 +65,7 @@ { var sp = scope.ServiceProvider; - await sp.GetRequiredService().MigrateAsync(CancellationToken.None); + await sp.GetRequiredService().MigrateAsync(CancellationToken.None); await new SeedData(sp).Seed(CancellationToken.None); } diff --git a/IdentityServer/v8/Spaces/src/Spaces/Seed/DemoCredentials.cs b/IdentityServer/v8/Spaces/src/Spaces/Seed/DemoCredentials.cs index d53ee8df..afb9f79e 100644 --- a/IdentityServer/v8/Spaces/src/Spaces/Seed/DemoCredentials.cs +++ b/IdentityServer/v8/Spaces/src/Spaces/Seed/DemoCredentials.cs @@ -10,7 +10,7 @@ namespace Spaces.Seed; public static class DemoCredentials { /// Demo password shared across all seeded users. - public const string Password = "Pa$$Word123"; + public const string Password = "Pa$$Word123456789"; /// /// Returns the demo username for a given space, or null if no demo user is seeded for that space. diff --git a/IdentityServer/v8/Spaces/src/Spaces/Seed/DemoUserAttributes.cs b/IdentityServer/v8/Spaces/src/Spaces/Seed/DemoUserAttributes.cs index 9a4ccee6..a385e959 100644 --- a/IdentityServer/v8/Spaces/src/Spaces/Seed/DemoUserAttributes.cs +++ b/IdentityServer/v8/Spaces/src/Spaces/Seed/DemoUserAttributes.cs @@ -1,14 +1,13 @@ // Copyright (c) Duende Software. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. -using Duende.IdentityServer.Models; -using Duende.IdentityServer.Stores.Storage; using Duende.Storage.EntityAttributeValue; namespace Spaces.Seed; /// -/// Set of example attributes that are used during authentication +/// Example attributes added to the built-in user profile schema, which already contains +/// email, name, given_name and family_name. /// public class DemoUserAttributes { @@ -21,78 +20,4 @@ public class DemoUserAttributes // 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") - }; -} - -internal static class OidcProviderSchema -{ - /// The base address of the OIDC provider (e.g. https://idp.example.com). - public static readonly TypedAttributeDefinition Authority = - new(AttributeCode.Create("Authority"), new ScalarAttributeType(ScalarDataType.String)); - - /// The client ID used to authenticate with the external OIDC provider. - public static readonly TypedAttributeDefinition ClientId = - new(AttributeCode.Create("ClientId"), new ScalarAttributeType(ScalarDataType.String)); - - /// The client secret used to authenticate with the external OIDC provider. - public static readonly TypedAttributeDefinition ClientSecret = - new(AttributeCode.Create("ClientSecret"), new ScalarAttributeType(ScalarDataType.String)); - - /// The response type (e.g. id_token). Defaults to id_token if not set. - public static readonly TypedAttributeDefinition ResponseType = - new(AttributeCode.Create("ResponseType"), new ScalarAttributeType(ScalarDataType.String)); - - /// Space-separated scope values (e.g. openid profile). Defaults to openid if not set. - public static readonly TypedAttributeDefinition Scope = - new(AttributeCode.Create("Scope"), new ScalarAttributeType(ScalarDataType.String)); - - /// - /// Whether to contact the userinfo endpoint. Stored as "true" or "false". - /// Defaults to true if not set. - /// - /// - /// Defined as string (not bool) because reads these - /// values from the Properties dictionary via string comparison, and - /// must round-trip them as strings. - /// - public static readonly TypedAttributeDefinition GetClaimsFromUserInfoEndpoint = - new(AttributeCode.Create("GetClaimsFromUserInfoEndpoint"), new ScalarAttributeType(ScalarDataType.String)); - - /// - /// Whether PKCE should be used. Stored as "true" or "false". - /// Defaults to true if not set. - /// - /// - /// Defined as string (not bool) because reads these - /// values from the Properties dictionary via string comparison, and - /// must round-trip them as strings. - /// - public static readonly TypedAttributeDefinition UsePkce = - new(AttributeCode.Create("UsePkce"), new ScalarAttributeType(ScalarDataType.String)); - - /// - /// The built-in schema for OIDC identity providers, registered with schema ID idp:oidc. - /// - public static readonly SchemaConfiguration Schema = new() - { - SchemaId = SchemaId.IdentityProvider("oidc"), - DisplayName = "OIDC Identity Provider", - Description = "Built-in schema for OpenID Connect identity providers.", - AttributeDefinitions = - [ - Authority, - ClientId, - ClientSecret, - ResponseType, - Scope, - GetClaimsFromUserInfoEndpoint, - UsePkce - ] - }; } diff --git a/IdentityServer/v8/Spaces/src/Spaces/Seed/SeedData.cs b/IdentityServer/v8/Spaces/src/Spaces/Seed/SeedData.cs index 0d59c117..6368078f 100644 --- a/IdentityServer/v8/Spaces/src/Spaces/Seed/SeedData.cs +++ b/IdentityServer/v8/Spaces/src/Spaces/Seed/SeedData.cs @@ -7,10 +7,12 @@ using Duende.IdentityServer.Admin.IdentityProviders; using Duende.IdentityServer.Models; using Duende.IdentityServer.Stores.Storage; +using Duende.IdentityServer.Stores.Storage.IdentityProviders; using Duende.Spaces; using Duende.Storage.EntityAttributeValue; using Duende.UserManagement; using Duende.UserManagement.Authentication; +using Duende.UserManagement.Profiles; namespace Spaces.Seed; @@ -68,13 +70,13 @@ private async Task CreateIdp(SpaceId spaceId, string name, CancellationToken ct) var spacedService = services.GetServiceForSpace(spaceId); var schemaStore = services.GetRequiredService(); - var oidcSchema = await schemaStore.GetAsync(SchemaId.IdentityProvider("oidc"), ct); + var oidcSchema = await schemaStore.GetAsync(SchemaId.OidcIdentityProvider, ct); var values = new AttributeValueCollection(oidcSchema); - values.Set(OidcProviderSchema.Authority, "https://demo.duendesoftware.com"); - values.Set(OidcProviderSchema.ClientId, "interactive.confidential"); - values.Set(OidcProviderSchema.ClientSecret, "secret"); - values.Set(OidcProviderSchema.ResponseType, "code"); - values.Set(OidcProviderSchema.GetClaimsFromUserInfoEndpoint, "true"); + values.Set(DefaultOidcProviderSchema.Authority, "https://demo.duendesoftware.com"); + values.Set(DefaultOidcProviderSchema.ClientId, "interactive.confidential"); + values.Set(DefaultOidcProviderSchema.ClientSecret, "secret"); + values.Set(DefaultOidcProviderSchema.ResponseType, "code"); + values.Set(DefaultOidcProviderSchema.GetClaimsFromUserInfoEndpoint, "true"); var saveResult = await spacedService.Service.CreateAsync(new CreateIdentityProvider() { @@ -120,9 +122,11 @@ private async Task CreateUser(SpaceId spaceId, string userName, CancellationToke 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. + // First, we'll need to create a profile for the user. It has a unique username, and the + // built-in schema requires a unique email. var attributes = new AttributeValueCollection(await profileAdmin.GetSchemaAsync(ct)); attributes.Set(DemoUserAttributes.UserName, userName); // This username is used for username / password login. + attributes.Set(OidcStandardAttributes.Email, $"{userName}@example.com"); var userSubjectId = UserSubjectId.New(); var saved = await profileAdmin.TryAddAsync(userSubjectId, attributes.Validate(), ct); diff --git a/IdentityServer/v8/Spaces/src/Spaces/appsettings.Development.json b/IdentityServer/v8/Spaces/src/Spaces/appsettings.Development.json index c8fe18bb..6f8ab54b 100644 --- a/IdentityServer/v8/Spaces/src/Spaces/appsettings.Development.json +++ b/IdentityServer/v8/Spaces/src/Spaces/appsettings.Development.json @@ -11,7 +11,7 @@ // ⚠ LOCAL DEVELOPMENT DEFAULTS — demo credentials for the sample only. // Never copy these values to a production or staging environment. "Demo": { - "Password": "Pa$$Word123", + "Password": "Pa$$Word123456789", "ClientSecret": "secret", "ShowCredentials": true } diff --git a/IdentityServer/v8/Spaces/tests/Spaces.PlaywrightTests/Tests/LoginTests.cs b/IdentityServer/v8/Spaces/tests/Spaces.PlaywrightTests/Tests/LoginTests.cs index d1604f07..37b5ab4a 100644 --- a/IdentityServer/v8/Spaces/tests/Spaces.PlaywrightTests/Tests/LoginTests.cs +++ b/IdentityServer/v8/Spaces/tests/Spaces.PlaywrightTests/Tests/LoginTests.cs @@ -14,7 +14,7 @@ public async Task Can_login_to_default_space() 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.FillAsync("[name=Password]", "Pa$$Word123456789"); await page.ClickAsync("button[type=submit]"); await page.WaitForURLAsync(url => !url.Contains("/Account/Login")); @@ -30,7 +30,7 @@ public async Task Can_login_to_space1_via_hostname() 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.FillAsync("[name=Password]", "Pa$$Word123456789"); await page.ClickAsync("button[type=submit]"); await page.WaitForURLAsync(url => !url.Contains("/Account/Login")); diff --git a/IdentityServer/v8/Storage/Program.cs b/IdentityServer/v8/Storage/Program.cs index 3c3f4d86..4c1d7c41 100644 --- a/IdentityServer/v8/Storage/Program.cs +++ b/IdentityServer/v8/Storage/Program.cs @@ -17,7 +17,6 @@ using Storage; const string defaultBaseUrl = "https://localhost:5006"; -const string databaseName = "IdentityServerStorageSample"; var builder = WebApplication.CreateBuilder(args); var publicBaseUri = GetPublicBaseUri(builder.Configuration["SampleBaseUrl"] ?? defaultBaseUrl); @@ -41,14 +40,29 @@ }) .AddDeveloperSigningCredential(persistKey: false) .AddServerSideSessions() - .AddStorage(storage => storage.AddSqliteInMemoryStore(databaseName)) + // AddStorage only registers a database for a named instance; it does not route any + // IdentityServer data to it. AddConfigurationStorage/AddOperationalStorage below route + // each data category to one of these two databases. A category left unmapped would fall + // back to a default instance, but this sample maps both categories explicitly, so no + // default instance is registered. + .AddStorage(SampleStorage.Configuration, storage => storage.AddSqliteInMemory("IdentityServerStorageSample-Configuration")) + .AddStorage(SampleStorage.Operational, storage => storage.AddSqliteInMemory("IdentityServerStorageSample-Operational")) + .AddConfigurationStorage(SampleStorage.Configuration) + .AddOperationalStorage(SampleStorage.Operational) .AddInMemoryDataExtensionSchemas([SampleData.ClientSchema]) .AddTestUsers(SampleUsers.Users) .AddCustomTokenRequestValidator(); var app = builder.Build(); -await app.Services.GetRequiredService().MigrateAsync(CancellationToken.None); +// Each storage instance is a separate database and is migrated separately. +var schemaFactory = app.Services.GetRequiredService(); +foreach (var instance in new[] { SampleStorage.Configuration, SampleStorage.Operational }) +{ + var schema = await schemaFactory.GetStorageInstanceSchema(instance, CancellationToken.None); + await schema.MigrateAsync(CancellationToken.None); +} + await SampleData.InitializeAsync(app.Services, publicBaseUrl, CancellationToken.None); app.UseHostFiltering(); diff --git a/IdentityServer/v8/Storage/README.md b/IdentityServer/v8/Storage/README.md index 6c9e20cc..fcc3b8df 100644 --- a/IdentityServer/v8/Storage/README.md +++ b/IdentityServer/v8/Storage/README.md @@ -1,6 +1,6 @@ # Duende Storage sample -This sample runs IdentityServer with configuration and operational data stored in a shared in-memory SQLite database through Duende Storage. It uses the IdentityServer administration APIs to create all IdentityServer configuration at startup and registers a fixed in-memory schema for client extension properties. +This sample runs IdentityServer with configuration and operational data stored in two separate in-memory SQLite databases through Duende Storage. It uses the IdentityServer administration APIs to create all IdentityServer configuration at startup and registers a fixed in-memory schema for client extension properties. The sample demonstrates: @@ -8,15 +8,16 @@ The sample demonstrates: - creating scopes, identity resources, clients, secrets, and extension-property values with the administration APIs; - obtaining a client-credentials token from configuration loaded through Duende Storage; - enforcing an `allowed_region` client extension property in a custom token-request validator; -- signing in a test user with authorization code and PKCE to create a storage-backed server-side session; and -- searching stored clients and inspecting stored sessions. +- signing in a test user with authorization code and PKCE to create a storage-backed server-side session; +- searching stored clients and inspecting stored sessions; and +- routing IdentityServer configuration data and operational data to two independently registered and migrated storage instances. ## Run the sample The sample uses the latest prerelease packages required by this functionality: -- `Duende.IdentityServer` `8.1.0-preview.3` -- `Duende.Storage.Sqlite` `2.0.0-preview.2` +- `Duende.IdentityServer` `8.1.0-preview.4` +- `Duende.Storage.Sqlite` `2.0.0-preview.3` From this directory, run: @@ -24,7 +25,36 @@ From this directory, run: dotnet run ``` -Open `https://localhost:5006` if the browser does not open automatically. The database is held in memory and is reset whenever the process stops. +Open `https://localhost:5006` if the browser does not open automatically. Both databases are held in memory and reset whenever the process stops. + +## Multiple storage instances + +`SampleStorage` names two Duende Storage instances: `configuration` and `operational`. `Program.cs` registers a separate in-memory SQLite database for each and routes IdentityServer's data categories to them: + +```csharp +builder.Services + .AddIdentityServer() + // ... + .AddStorage(SampleStorage.Configuration, storage => storage.AddSqliteInMemory("IdentityServerStorageSample-Configuration")) + .AddStorage(SampleStorage.Operational, storage => storage.AddSqliteInMemory("IdentityServerStorageSample-Operational")) + .AddConfigurationStorage(SampleStorage.Configuration) + .AddOperationalStorage(SampleStorage.Operational); +``` + +`AddStorage` only registers a database for a named instance; it does not route any data to it. `AddConfigurationStorage`/`AddOperationalStorage` route a data category (clients, identity providers, and resources for configuration; persisted grants, server-side sessions, and keys for operational) to the instance passed to them. A data category left unmapped falls back to a default instance, but this sample maps both categories explicitly, so it registers no default instance. + +Each instance is a separate database and must be migrated separately, through `IStorageInstanceSchemaFactory`: + +```csharp +var schemaFactory = app.Services.GetRequiredService(); +foreach (var instance in new[] { SampleStorage.Configuration, SampleStorage.Operational }) +{ + var schema = await schemaFactory.GetStorageInstanceSchema(instance, CancellationToken.None); + await schema.MigrateAsync(CancellationToken.None); +} +``` + +In production, each instance would typically be a separate database and connection string, for example `AddPostgreSql()` for one instance and `AddMsSql()` for another, migrated separately and independently scaled. Each instance can use a different storage provider. ## Client credentials and the region extension diff --git a/IdentityServer/v8/Storage/SamplePage.cs b/IdentityServer/v8/Storage/SamplePage.cs index 246fb932..86b98cab 100644 --- a/IdentityServer/v8/Storage/SamplePage.cs +++ b/IdentityServer/v8/Storage/SamplePage.cs @@ -31,9 +31,19 @@ internal static string Create(string baseUrl)

Storage-backed IdentityServer

- This host stores IdentityServer configuration and operational data in an in-memory SQLite database. - Startup registers a fixed in-memory schema, then creates scopes, identity resources, clients, and typed - client extension-property values through the administration APIs. + This host stores IdentityServer configuration and operational data in two separate in-memory + SQLite databases, one per named storage instance. Startup registers a fixed in-memory schema, + then creates scopes, identity resources, clients, and typed client extension-property values + through the administration APIs. +

+ +

Storage instances

+

+ This sample registers two named Duende Storage instances, each its own in-memory SQLite + database migrated separately: {{SampleStorage.Configuration}} holds clients, + identity resources, and API scopes; {{SampleStorage.Operational}} holds server-side + sessions, persisted grants, and signing keys. /sample/clients reads from the + configuration instance and /sample/sessions reads from the operational instance.

Inspect configuration

diff --git a/IdentityServer/v8/Storage/SampleStorage.cs b/IdentityServer/v8/Storage/SampleStorage.cs new file mode 100644 index 00000000..4aaeba76 --- /dev/null +++ b/IdentityServer/v8/Storage/SampleStorage.cs @@ -0,0 +1,16 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Duende.Storage; + +namespace Storage; + +/// +/// Names the two Duende Storage instances this sample registers: one for IdentityServer +/// configuration data and one for operational data. +/// +internal static class SampleStorage +{ + internal static readonly StorageInstanceId Configuration = StorageInstanceId.Create("configuration"); + internal static readonly StorageInstanceId Operational = StorageInstanceId.Create("operational"); +} diff --git a/IdentityServer/v8/Storage/Storage.csproj b/IdentityServer/v8/Storage/Storage.csproj index eabd93e5..753772ce 100644 --- a/IdentityServer/v8/Storage/Storage.csproj +++ b/IdentityServer/v8/Storage/Storage.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/IdentityServer/v8/UserManagement/AccountLockout/AccountLockout/AccountLockout.csproj b/IdentityServer/v8/UserManagement/AccountLockout/AccountLockout/AccountLockout.csproj index f9c7ac08..13e40b0a 100644 --- a/IdentityServer/v8/UserManagement/AccountLockout/AccountLockout/AccountLockout.csproj +++ b/IdentityServer/v8/UserManagement/AccountLockout/AccountLockout/AccountLockout.csproj @@ -7,9 +7,9 @@ - - - + + + diff --git a/IdentityServer/v8/UserManagement/AccountLockout/AccountLockout/Program.cs b/IdentityServer/v8/UserManagement/AccountLockout/AccountLockout/Program.cs index 3927d1d1..8175290a 100644 --- a/IdentityServer/v8/UserManagement/AccountLockout/AccountLockout/Program.cs +++ b/IdentityServer/v8/UserManagement/AccountLockout/AccountLockout/Program.cs @@ -61,6 +61,8 @@ options.Events.RaiseSuccessEvents = true; }) .AddDeveloperSigningCredential(persistKey: false) + .AddStorage(storage => storage.AddSqlite(options => options.ConnectionString = $"Data Source={databasePath}")) + .AddDynamicSchemas() .AddUserManagement(userManagement => { userManagement.Authentication(authentication => @@ -74,8 +76,6 @@ _ = authentication.UseSmtpOtpDispatcher(options => builder.Configuration.GetSection("Smtp").Bind(options)); }); - - userManagement.AddSqliteStore(options => options.ConnectionString = $"Data Source={databasePath}"); }); // Replace the default passkey sign-in step so policy runs after WebAuthn verification, @@ -89,7 +89,7 @@ var services = scope.ServiceProvider; // A sample starts from an empty SQLite database, so create storage, schema, and users in order. - await services.GetRequiredService().MigrateAsync(CancellationToken.None); + await services.GetRequiredService().MigrateAsync(CancellationToken.None); await services.GetRequiredService().BootstrapAsync(CancellationToken.None); await services.GetRequiredService().SeedAsync(CancellationToken.None); }