Skip to content

Commit 238080c

Browse files
committed
feat: add configuration-backed Keycloak demo providers
1 parent eb07aa2 commit 238080c

7 files changed

Lines changed: 270 additions & 6 deletions

File tree

specs/012-external-authentication/contracts/runtime-contracts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ services.AddElsa(elsa =>
376376
elsa.UseExternalAuthentication(external =>
377377
{
378378
external.Configure(options =>
379-
configuration.GetSection("ExternalAuthentication").Bind(options));
379+
configuration.GetSection("ExternalAuthentication").BindExternalAuthenticationOptions(options));
380380

381381
external.UseOpenIdConnect();
382382
external.UseElsaSecrets();

specs/012-external-authentication/quickstart.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ services.AddElsa(elsa =>
2020
elsa.UseExternalAuthentication(feature =>
2121
{
2222
feature.ConfigureOptions = options =>
23-
configuration.GetSection("ExternalAuthentication").Bind(options);
23+
configuration.GetSection("ExternalAuthentication").BindExternalAuthenticationOptions(options);
2424
});
2525
});
2626

@@ -119,8 +119,9 @@ Authentication__ExternalAuthentication__ClientSecret={strong-random-value}
119119
"displayOrder": 10,
120120
"isPreferred": true,
121121
"isEnabled": true,
122-
"adapterSettingsVersion": 1,
122+
"adapterSettingsVersion": 2,
123123
"adapterSettings": {
124+
"mode": "discovery",
124125
"discoveryUrl": "https://login.contoso.example/.well-known/openid-configuration",
125126
"clientId": "elsa-server",
126127
"scopes": ["openid", "profile", "email", "groups"],

src/apps/Elsa.ModularServer.Web/appsettings.json

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,178 @@
6262
"LocalLogin": {
6363
"IsEnabled": true
6464
},
65+
"Redirects": {
66+
"ExternalCallbackBaseUri": "https://localhost:7294/elsa/api/"
67+
},
68+
"ProviderEgress": {
69+
"RequireHttps": true,
70+
"AllowPrivateNetworkDestinations": true,
71+
"AllowedHosts": [
72+
"localhost"
73+
]
74+
},
75+
"UnlinkedIdentityPolicy": {
76+
"DefaultType": "reject",
77+
"AllowDatabaseConnectionOverride": true
78+
},
79+
"Connections": [
80+
{
81+
"Id": "keycloak-idp-configuration",
82+
"Key": "keycloak-idp",
83+
"AdapterType": "openid-connect",
84+
"AdapterSettingsVersion": 2,
85+
"AdapterSettings": {
86+
"mode": "discovery",
87+
"discoveryUrl": "https://localhost:8443/realms/elsa/.well-known/openid-configuration",
88+
"clientId": "elsa-studio-idp",
89+
"clientAuthenticationMethod": "client_secret_basic",
90+
"scopes": [
91+
"profile",
92+
"email"
93+
]
94+
},
95+
"SecretBindings": {
96+
"clientSecret": {
97+
"Ownership": "External",
98+
"ResolverType": "configuration",
99+
"Reference": "ExternalAuthentication:Secrets:KeycloakGeneralClientSecret",
100+
"ExpectedType": "text",
101+
"ExpectedScope": "external-authentication"
102+
}
103+
},
104+
"DisplayName": "Keycloak General",
105+
"DisplayOrder": 10,
106+
"IsPreferred": true,
107+
"IsEnabled": true,
108+
"UnlinkedPolicy": {
109+
"Type": "create-user",
110+
"SettingsVersion": 1,
111+
"Settings": {
112+
"defaultRoleIds": [
113+
"admin"
114+
]
115+
}
116+
},
117+
"ClaimProjection": {
118+
"AllowedClaimTypes": [
119+
"preferred_username",
120+
"name",
121+
"given_name",
122+
"family_name",
123+
"email"
124+
],
125+
"RedactedClaimTypes": [],
126+
"MaximumClaimCount": 64,
127+
"MaximumValueLength": 1024,
128+
"MaximumTotalBytes": 16384
129+
},
130+
"UpstreamLogoutMode": "UserChoice"
131+
},
132+
{
133+
"Id": "keycloak-workforce-configuration",
134+
"Key": "keycloak-workforce",
135+
"AdapterType": "openid-connect",
136+
"AdapterSettingsVersion": 2,
137+
"AdapterSettings": {
138+
"mode": "discovery",
139+
"discoveryUrl": "https://localhost:8443/realms/elsa-workforce/.well-known/openid-configuration",
140+
"clientId": "elsa-workforce-idp",
141+
"clientAuthenticationMethod": "client_secret_basic",
142+
"scopes": [
143+
"profile",
144+
"email"
145+
]
146+
},
147+
"SecretBindings": {
148+
"clientSecret": {
149+
"Ownership": "External",
150+
"ResolverType": "configuration",
151+
"Reference": "ExternalAuthentication:Secrets:KeycloakWorkforceClientSecret",
152+
"ExpectedType": "text",
153+
"ExpectedScope": "external-authentication"
154+
}
155+
},
156+
"DisplayName": "Keycloak Workforce",
157+
"DisplayOrder": 20,
158+
"IsPreferred": false,
159+
"IsEnabled": true,
160+
"UnlinkedPolicy": {
161+
"Type": "create-user",
162+
"SettingsVersion": 1,
163+
"Settings": {
164+
"defaultRoleIds": [
165+
"admin"
166+
]
167+
}
168+
},
169+
"ClaimProjection": {
170+
"AllowedClaimTypes": [
171+
"preferred_username",
172+
"name",
173+
"given_name",
174+
"family_name",
175+
"email"
176+
],
177+
"RedactedClaimTypes": [],
178+
"MaximumClaimCount": 64,
179+
"MaximumValueLength": 1024,
180+
"MaximumTotalBytes": 16384
181+
},
182+
"UpstreamLogoutMode": "UserChoice"
183+
},
184+
{
185+
"Id": "keycloak-partners-configuration",
186+
"Key": "keycloak-partners",
187+
"AdapterType": "openid-connect",
188+
"AdapterSettingsVersion": 2,
189+
"AdapterSettings": {
190+
"mode": "discovery",
191+
"discoveryUrl": "https://localhost:8443/realms/elsa-partners/.well-known/openid-configuration",
192+
"clientId": "elsa-partners-idp",
193+
"clientAuthenticationMethod": "client_secret_basic",
194+
"scopes": [
195+
"profile",
196+
"email"
197+
]
198+
},
199+
"SecretBindings": {
200+
"clientSecret": {
201+
"Ownership": "External",
202+
"ResolverType": "configuration",
203+
"Reference": "ExternalAuthentication:Secrets:KeycloakPartnersClientSecret",
204+
"ExpectedType": "text",
205+
"ExpectedScope": "external-authentication"
206+
}
207+
},
208+
"DisplayName": "Keycloak Partners",
209+
"DisplayOrder": 30,
210+
"IsPreferred": false,
211+
"IsEnabled": true,
212+
"UnlinkedPolicy": {
213+
"Type": "create-user",
214+
"SettingsVersion": 1,
215+
"Settings": {
216+
"defaultRoleIds": [
217+
"admin"
218+
]
219+
}
220+
},
221+
"ClaimProjection": {
222+
"AllowedClaimTypes": [
223+
"preferred_username",
224+
"name",
225+
"given_name",
226+
"family_name",
227+
"email"
228+
],
229+
"RedactedClaimTypes": [],
230+
"MaximumClaimCount": 64,
231+
"MaximumValueLength": 1024,
232+
"MaximumTotalBytes": 16384
233+
},
234+
"UpstreamLogoutMode": "UserChoice"
235+
}
236+
],
65237
"AuthenticationClients": [
66238
{
67239
"ClientId": "elsa-studio-server",
@@ -85,7 +257,10 @@
85257
}
86258
],
87259
"Secrets": {
88-
"StudioServerClientSecret": "t/6A8piT0E1hbxBSqkppC9Ug2Nr3B9E++GBt10fwhu4="
260+
"StudioServerClientSecret": "t/6A8piT0E1hbxBSqkppC9Ug2Nr3B9E++GBt10fwhu4=",
261+
"KeycloakGeneralClientSecret": "elsa-keycloak-client-secret-dev",
262+
"KeycloakWorkforceClientSecret": "elsa-workforce-client-secret-dev",
263+
"KeycloakPartnersClientSecret": "elsa-partners-client-secret-dev"
89264
}
90265
},
91266
"SecretsJavaScript": {},
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System.Text.Json;
2+
using Elsa.ExternalAuthentication.Models;
3+
using Elsa.ExternalAuthentication.Options;
4+
using Microsoft.Extensions.Configuration;
5+
6+
namespace Elsa.Extensions;
7+
8+
public static class ExternalAuthenticationConfigurationExtensions
9+
{
10+
/// <summary>
11+
/// Binds external-authentication options and reconstructs configuration-backed JSON settings.
12+
/// </summary>
13+
public static void BindExternalAuthenticationOptions(this IConfigurationSection section, ExternalAuthenticationOptions options)
14+
{
15+
section.Bind(options);
16+
17+
var connectionSections = GetIndexedChildren(section.GetSection("Connections"));
18+
var connections = options.ConfigurationConnections.ToArray();
19+
for (var i = 0; i < Math.Min(connectionSections.Length, connections.Length); i++)
20+
BindJsonSettings(connectionSections[i], connections[i]);
21+
}
22+
23+
private static void BindJsonSettings(IConfigurationSection section, IdentityProviderConnection connection)
24+
{
25+
connection.AdapterSettings = GetJsonElement(section, "AdapterSettings");
26+
27+
if (connection.UnlinkedPolicy is not null)
28+
connection.UnlinkedPolicy = connection.UnlinkedPolicy with { Settings = GetJsonElement(section.GetSection("UnlinkedPolicy"), "Settings") };
29+
30+
var grantSourceSections = GetIndexedChildren(section.GetSection("PermissionGrantSources"));
31+
var grantSources = connection.PermissionGrantSources.ToArray();
32+
connection.PermissionGrantSources = grantSources
33+
.Select((source, index) => index < grantSourceSections.Length
34+
? source with { Settings = GetJsonElement(grantSourceSections[index], "Settings") }
35+
: source)
36+
.ToArray();
37+
}
38+
39+
private static JsonElement GetJsonElement(IConfiguration configuration, string sectionKey)
40+
{
41+
var json = configuration.GetSectionAsJson(sectionKey);
42+
return json is null ? default : JsonSerializer.Deserialize<JsonElement>(json);
43+
}
44+
45+
private static IConfigurationSection[] GetIndexedChildren(IConfigurationSection section) =>
46+
section.GetChildren()
47+
.OrderBy(child => int.TryParse(child.Key, out var index) ? index : int.MaxValue)
48+
.ToArray();
49+
}

src/modules/Elsa.ExternalAuthentication/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ services.AddElsa(elsa =>
1212
elsa.UseExternalAuthentication(feature =>
1313
{
1414
feature.ConfigureOptions = options =>
15-
configuration.GetSection("ExternalAuthentication").Bind(options);
15+
configuration.GetSection("ExternalAuthentication").BindExternalAuthenticationOptions(options);
1616
});
1717
});
1818

1919
services.AddOpenIdConnectExternalAuthentication();
2020
```
2121

22+
Use `BindExternalAuthenticationOptions` for `IConfiguration` binding so the arbitrary JSON envelopes used by adapter, policy, and grant-source settings are reconstructed from their configuration sections. Direct programmatic option configuration is unaffected.
23+
2224
`AddExternalAuthenticationServices` supplies in-memory stores suitable for single-node development. A multi-node deployment must replace broker state, grants, sessions, observations, registry versions, and identity links with shared durable implementations, share ASP.NET Core Data Protection keys, and configure the same `HandleHashing:SharedKeyBase64` on every node.
2325

2426
## Configuration ownership

src/modules/Elsa.ExternalAuthentication/ShellFeatures/ExternalAuthenticationShellFeature.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using CShells.Configuration;
22
using CShells.FastEndpoints.Features;
33
using CShells.Features;
4+
using Elsa.Extensions;
45
using Elsa.ExternalAuthentication.Options;
56
using JetBrains.Annotations;
67
using Microsoft.Extensions.Configuration;
@@ -21,6 +22,6 @@ public void ConfigureServices(IServiceCollection services)
2122
{
2223
services.AddExternalAuthenticationServices();
2324
services.AddOptions<ExternalAuthenticationOptions>()
24-
.Configure<ShellConfiguration>((options, configuration) => configuration.GetSection(ConfigurationSectionName).Bind(options));
25+
.Configure<ShellConfiguration>((options, configuration) => configuration.GetSection(ConfigurationSectionName).BindExternalAuthenticationOptions(options));
2526
}
2627
}

test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/ExternalAuthenticationOptionsTests.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Elsa.ExternalAuthentication.Models;
33
using Elsa.ExternalAuthentication.Options;
44
using Elsa.ExternalAuthentication.Validation;
5+
using Elsa.Extensions;
56
using Microsoft.Extensions.Configuration;
67

78
namespace Elsa.ExternalAuthentication.UnitTests.Foundational;
@@ -41,6 +42,41 @@ public void BindsAuthenticationClientsFromConfiguration()
4142
Assert.True(client.IsEnabled);
4243
}
4344

45+
[Fact]
46+
public void BindsConnectionJsonSettingsFromConfiguration()
47+
{
48+
var configuration = new ConfigurationBuilder()
49+
.AddInMemoryCollection(new Dictionary<string, string?>
50+
{
51+
["ExternalAuthentication:Connections:0:Key"] = "keycloak",
52+
["ExternalAuthentication:Connections:0:AdapterType"] = "openid-connect",
53+
["ExternalAuthentication:Connections:0:AdapterSettings:mode"] = "discovery",
54+
["ExternalAuthentication:Connections:0:AdapterSettings:discoveryUrl"] = "https://localhost:8443/realms/elsa/.well-known/openid-configuration",
55+
["ExternalAuthentication:Connections:0:AdapterSettings:clientId"] = "elsa-studio-idp",
56+
["ExternalAuthentication:Connections:0:AdapterSettings:scopes:0"] = "profile",
57+
["ExternalAuthentication:Connections:0:AdapterSettings:scopes:1"] = "email",
58+
["ExternalAuthentication:Connections:0:UnlinkedPolicy:Type"] = "create-user",
59+
["ExternalAuthentication:Connections:0:UnlinkedPolicy:SettingsVersion"] = "1",
60+
["ExternalAuthentication:Connections:0:UnlinkedPolicy:Settings:defaultRoleIds:0"] = "admin",
61+
["ExternalAuthentication:Connections:0:PermissionGrantSources:0:Type"] = "claim-mapping",
62+
["ExternalAuthentication:Connections:0:PermissionGrantSources:0:SettingsVersion"] = "1",
63+
["ExternalAuthentication:Connections:0:PermissionGrantSources:0:Order"] = "10",
64+
["ExternalAuthentication:Connections:0:PermissionGrantSources:0:Settings:claimType"] = "groups"
65+
})
66+
.Build();
67+
var options = new ExternalAuthenticationOptions();
68+
69+
configuration.GetSection("ExternalAuthentication").BindExternalAuthenticationOptions(options);
70+
71+
var connection = Assert.Single(options.ConfigurationConnections);
72+
Assert.Equal("discovery", connection.AdapterSettings.GetProperty("mode").GetString());
73+
Assert.Equal("https://localhost:8443/realms/elsa/.well-known/openid-configuration", connection.AdapterSettings.GetProperty("discoveryUrl").GetString());
74+
Assert.Equal("elsa-studio-idp", connection.AdapterSettings.GetProperty("clientId").GetString());
75+
Assert.Equal(["profile", "email"], connection.AdapterSettings.GetProperty("scopes").EnumerateArray().Select(x => x.GetString()));
76+
Assert.Equal("admin", connection.UnlinkedPolicy?.Settings.GetProperty("defaultRoleIds")[0].GetString());
77+
Assert.Equal("groups", Assert.Single(connection.PermissionGrantSources).Settings.GetProperty("claimType").GetString());
78+
}
79+
4480
[Fact]
4581
public void DefaultsFavorTheMostRestrictiveOperationalSettings()
4682
{

0 commit comments

Comments
 (0)