Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions IdentityServer/v8/Spaces/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Duende.IdentityServer" Version="8.1.0-preview.3" />
<PackageVersion Include="Duende.Spaces" Version="1.0.0-preview.2" />
<PackageVersion Include="Duende.Storage" Version="2.0.0-preview.2" />
<PackageVersion Include="Duende.Storage.Sqlite" Version="2.0.0-preview.2" />
<PackageVersion Include="Duende.UserManagement.IdentityServer8" Version="2.0.0-preview.2" />
<PackageVersion Include="Duende.IdentityServer" Version="8.1.0-preview.4" />
<PackageVersion Include="Duende.Spaces" Version="1.0.0-preview.3" />
<PackageVersion Include="Duende.Storage" Version="2.0.0-preview.3" />
<PackageVersion Include="Duende.Storage.Sqlite" Version="2.0.0-preview.3" />
<PackageVersion Include="Duende.UserManagement.IdentityServer8" Version="2.0.0-preview.3" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.AppHost" Version="13.5.3" />
Expand Down
2 changes: 1 addition & 1 deletion IdentityServer/v8/Spaces/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------|-------------------|-------------|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,16 @@ public async Task<IActionResult> 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)
Expand Down
16 changes: 7 additions & 9 deletions IdentityServer/v8/Spaces/src/Spaces/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpaceClaimAugmentationProfileService>()
.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<ICustomTokenRequestValidator, AddSpaceNameToClaimsRequestValidator>();
builder.Services.AddTransient<UserManagementProfileService>();
Expand All @@ -67,7 +65,7 @@
{
var sp = scope.ServiceProvider;

await sp.GetRequiredService<IDatabaseSchema>().MigrateAsync(CancellationToken.None);
await sp.GetRequiredService<IStorageInstanceSchema>().MigrateAsync(CancellationToken.None);

await new SeedData(sp).Seed(CancellationToken.None);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace Spaces.Seed;
public static class DemoCredentials
{
/// <summary>Demo password shared across all seeded users.</summary>
public const string Password = "Pa$$Word123";
public const string Password = "Pa$$Word123456789";

/// <summary>
/// Returns the demo username for a given space, or null if no demo user is seeded for that space.
Expand Down
79 changes: 2 additions & 77 deletions IdentityServer/v8/Spaces/src/Spaces/Seed/DemoUserAttributes.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public class DemoUserAttributes
{
Expand All @@ -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
{
/// <summary>The base address of the OIDC provider (e.g. <c>https://idp.example.com</c>).</summary>
public static readonly TypedAttributeDefinition<string> Authority =
new(AttributeCode.Create("Authority"), new ScalarAttributeType(ScalarDataType.String));

/// <summary>The client ID used to authenticate with the external OIDC provider.</summary>
public static readonly TypedAttributeDefinition<string> ClientId =
new(AttributeCode.Create("ClientId"), new ScalarAttributeType(ScalarDataType.String));

/// <summary>The client secret used to authenticate with the external OIDC provider.</summary>
public static readonly TypedAttributeDefinition<string> ClientSecret =
new(AttributeCode.Create("ClientSecret"), new ScalarAttributeType(ScalarDataType.String));

/// <summary>The response type (e.g. <c>id_token</c>). Defaults to <c>id_token</c> if not set.</summary>
public static readonly TypedAttributeDefinition<string> ResponseType =
new(AttributeCode.Create("ResponseType"), new ScalarAttributeType(ScalarDataType.String));

/// <summary>Space-separated scope values (e.g. <c>openid profile</c>). Defaults to <c>openid</c> if not set.</summary>
public static readonly TypedAttributeDefinition<string> Scope =
new(AttributeCode.Create("Scope"), new ScalarAttributeType(ScalarDataType.String));

/// <summary>
/// Whether to contact the userinfo endpoint. Stored as <c>"true"</c> or <c>"false"</c>.
/// Defaults to <c>true</c> if not set.
/// </summary>
/// <remarks>
/// Defined as <c>string</c> (not <c>bool</c>) because <see cref="OidcProvider"/> reads these
/// values from the Properties dictionary via string comparison, and
/// <see cref="EavPropertyMapper.ExtractStringProperties"/> must round-trip them as strings.
/// </remarks>
public static readonly TypedAttributeDefinition<string> GetClaimsFromUserInfoEndpoint =
new(AttributeCode.Create("GetClaimsFromUserInfoEndpoint"), new ScalarAttributeType(ScalarDataType.String));

/// <summary>
/// Whether PKCE should be used. Stored as <c>"true"</c> or <c>"false"</c>.
/// Defaults to <c>true</c> if not set.
/// </summary>
/// <remarks>
/// Defined as <c>string</c> (not <c>bool</c>) because <see cref="OidcProvider"/> reads these
/// values from the Properties dictionary via string comparison, and
/// <see cref="EavPropertyMapper.ExtractStringProperties"/> must round-trip them as strings.
/// </remarks>
public static readonly TypedAttributeDefinition<string> UsePkce =
new(AttributeCode.Create("UsePkce"), new ScalarAttributeType(ScalarDataType.String));

/// <summary>
/// The built-in schema for OIDC identity providers, registered with schema ID <c>idp:oidc</c>.
/// </summary>
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
]
};
}
18 changes: 11 additions & 7 deletions IdentityServer/v8/Spaces/src/Spaces/Seed/SeedData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -68,13 +70,13 @@ private async Task CreateIdp(SpaceId spaceId, string name, CancellationToken ct)
var spacedService = services.GetServiceForSpace<IIdentityProviderAdmin>(spaceId);
var schemaStore = services.GetRequiredService<ISchemaStore>();

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()
{
Expand Down Expand Up @@ -120,9 +122,11 @@ private async Task CreateUser(SpaceId spaceId, string userName, CancellationToke
using var spacedUserAdmin = services.GetServiceForSpace<IUserAdmin>(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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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"));
Expand Down
20 changes: 17 additions & 3 deletions IdentityServer/v8/Storage/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<AllowedRegionTokenRequestValidator>();

var app = builder.Build();

await app.Services.GetRequiredService<IDatabaseSchema>().MigrateAsync(CancellationToken.None);
// Each storage instance is a separate database and is migrated separately.
var schemaFactory = app.Services.GetRequiredService<IStorageInstanceSchemaFactory>();
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();
Expand Down
42 changes: 36 additions & 6 deletions IdentityServer/v8/Storage/README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,60 @@
# 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:

- defining a fixed client data-extension schema and its typed `allowed_region` attribute in the sample;
- 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:

```console
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<IStorageInstanceSchemaFactory>();
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

Expand Down
Loading
Loading